react-x11 1.2.0 → 2.0.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/README.md +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- package/src/scene3d.js +0 -683
package/src/events.js
CHANGED
|
@@ -1,18 +1,131 @@
|
|
|
1
1
|
// Synthetic event system: ntk window events → capture/target/bubble dispatch
|
|
2
2
|
// over the drawn node tree, with hit testing, click synthesis, hover
|
|
3
|
-
// enter/leave, wheel
|
|
3
|
+
// enter/leave, the wheel and focus/Tab traversal.
|
|
4
4
|
// Handlers always read from current props, so updates never go stale.
|
|
5
5
|
import {
|
|
6
6
|
runWithPriority,
|
|
7
7
|
DiscreteEventPriority,
|
|
8
8
|
ContinuousEventPriority,
|
|
9
|
+
flushSyncWork,
|
|
9
10
|
} from './priority.js';
|
|
11
|
+
import { flushPendingFrames } from './frames.js';
|
|
12
|
+
import { callHandler, reportHandlerError } from './errors.js';
|
|
13
|
+
import { armDrag } from './dnd.js';
|
|
14
|
+
import { desktopSettings } from './desktopsettings.js';
|
|
15
|
+
import { noteInputTime } from './inputtime.js';
|
|
16
|
+
import { hooks as a11yHooks, isFocusable, effectivelyVisible } from './a11y.js';
|
|
17
|
+
import { Composer, composeTableFor } from './compose.js';
|
|
18
|
+
import { acceleratorKeysym } from './keyboard.js';
|
|
19
|
+
import { MOD } from './keysyms.js';
|
|
10
20
|
|
|
11
21
|
const XK_TAB = 0xff09;
|
|
12
|
-
const
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
22
|
+
const XK_ESCAPE = 0xff1b;
|
|
23
|
+
// The core protocol has no wheel: it is a click of button 4/5 (vertical) or
|
|
24
|
+
// 6/7 (horizontal). ntk derives its `wheel` event from those presses — or,
|
|
25
|
+
// where the connection has XI2, from the scroll valuators that carry the real
|
|
26
|
+
// distance — so the presses themselves are noise on the button path, the way
|
|
27
|
+
// a wheel release always was.
|
|
28
|
+
const WHEEL_BUTTONS = new Set([4, 5, 6, 7]);
|
|
29
|
+
/**
|
|
30
|
+
* How far one notch of the wheel scrolls, in pixels.
|
|
31
|
+
*
|
|
32
|
+
* ntk reports a scroll in **notches**: a mouse wheel's click, or the fraction
|
|
33
|
+
* of one a touchpad measured, `increment` being what the device says makes a
|
|
34
|
+
* whole one. That is the only unit a device agrees on — how far a notch
|
|
35
|
+
* *travels* is a toolkit's decision, and this is ours. Everything downstream
|
|
36
|
+
* of the dispatch is in pixels (`canScroll`/`scrollBy` take them, so does
|
|
37
|
+
* every registered element that answers the wheel), so the conversion happens
|
|
38
|
+
* here, once, and `deltaX`/`deltaY` mean pixels wherever they are read.
|
|
39
|
+
*
|
|
40
|
+
* The same step an arrow key takes (`SCROLL_KEY_STEP`, nodes.js), so a notch
|
|
41
|
+
* and an arrow press move a list by the same amount.
|
|
42
|
+
*/
|
|
43
|
+
export const WHEEL_NOTCH_PX = 48;
|
|
44
|
+
const RIGHT_BUTTON = 3;
|
|
45
|
+
|
|
46
|
+
/** How many leading entries two node paths share. */
|
|
47
|
+
function sharedPrefix(a, b) {
|
|
48
|
+
const limit = Math.min(a.length, b.length);
|
|
49
|
+
let n = 0;
|
|
50
|
+
while (n < limit && a[n] === b[n]) n++;
|
|
51
|
+
return n;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 'MouseDown' → 'mouseDown', memoized — the set of names is small and
|
|
55
|
+
// closed, and dispatch runs at motion rate
|
|
56
|
+
const TYPE_NAMES = Object.create(null);
|
|
57
|
+
function eventType(name) {
|
|
58
|
+
return (TYPE_NAMES[name] ??= name[0].toLowerCase() + name.slice(1));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One synthetic event, methods on the prototype: they used to be built as
|
|
63
|
+
* four fresh closures on every event object, which at motion rate was
|
|
64
|
+
* steady allocation for the GC to chew on (issue #188). Handlers only ever
|
|
65
|
+
* call them as methods (`ev.capturePointer()`), the same contract DOM
|
|
66
|
+
* events have.
|
|
67
|
+
*/
|
|
68
|
+
class SyntheticEvent {
|
|
69
|
+
constructor(manager, type, native, target, extra) {
|
|
70
|
+
// Native coordinates are device pixels off the wire; handlers are
|
|
71
|
+
// application code, which thinks in the logical pixels it wrote its
|
|
72
|
+
// styles in — so the divide happens here, at the one door events leave
|
|
73
|
+
// by, and `localX` below subtracts an `abs` divided the same way
|
|
74
|
+
// (src/scale.js). Everything *internal* — hit testing, drag
|
|
75
|
+
// thresholds, the scroll accumulator — keeps reading `native`.
|
|
76
|
+
const s = manager.scale;
|
|
77
|
+
this._manager = manager;
|
|
78
|
+
this._targetNode = target;
|
|
79
|
+
this.type = type;
|
|
80
|
+
this.x = (native?.x ?? 0) / s;
|
|
81
|
+
this.y = (native?.y ?? 0) / s;
|
|
82
|
+
this.target = manager._public(target);
|
|
83
|
+
this.currentTarget = null;
|
|
84
|
+
this.nativeEvent = native;
|
|
85
|
+
// X11 modifier mask, DOM names: bit 0 Shift, bit 2 Control, bit 3 Mod1,
|
|
86
|
+
// bit 6 Mod4. Carried on every event, not just keys — shift+click needs
|
|
87
|
+
// it too, and so does Alt+drag.
|
|
88
|
+
//
|
|
89
|
+
// Mod1 is Alt and Mod4 is Super by *convention*: the protocol says only
|
|
90
|
+
// that there are eight modifier rows, and which keys sit in them is
|
|
91
|
+
// whatever the keymap says. Every toolkit ships the convention anyway
|
|
92
|
+
// (GTK and Qt decode exactly these two), and the setup that remaps them
|
|
93
|
+
// still has the raw mask on `nativeEvent.buttons`.
|
|
94
|
+
this.shiftKey = Boolean(native?.buttons & MOD.Shift);
|
|
95
|
+
this.ctrlKey = Boolean(native?.buttons & MOD.Control);
|
|
96
|
+
this.altKey = Boolean(native?.buttons & MOD.Alt);
|
|
97
|
+
this.metaKey = Boolean(native?.buttons & MOD.Super);
|
|
98
|
+
this.defaultPrevented = false;
|
|
99
|
+
this.propagationStopped = false;
|
|
100
|
+
if (extra) Object.assign(this, extra);
|
|
101
|
+
if (target.abs) {
|
|
102
|
+
this.localX = this.x - target.abs.x / s;
|
|
103
|
+
this.localY = this.y - target.abs.y / s;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
preventDefault() {
|
|
108
|
+
this.defaultPrevented = true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
stopPropagation() {
|
|
112
|
+
this.propagationStopped = true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Pointer capture, DOM-like: while captured, mousemove/mouseup go to the
|
|
116
|
+
// capturing node instead of whatever is under the pointer, so a drag
|
|
117
|
+
// keeps working past the widget's own bounds. Released automatically on
|
|
118
|
+
// mouseup and when the node unmounts.
|
|
119
|
+
capturePointer() {
|
|
120
|
+
this._manager.capturedNode = this._targetNode;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
releasePointer() {
|
|
124
|
+
if (this._manager.capturedNode === this._targetNode) {
|
|
125
|
+
this._manager.capturedNode = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
16
129
|
|
|
17
130
|
// Click-to-component hook (see ClickToComponent.js). At most one handler is
|
|
18
131
|
// installed, gated by REACT_X11_CLICK_TO_COMPONENT — checked ahead of the
|
|
@@ -23,25 +136,261 @@ export function setClickToComponentHandler(fn) {
|
|
|
23
136
|
clickToComponentHandler = fn;
|
|
24
137
|
}
|
|
25
138
|
|
|
139
|
+
// DevTools' element picker (see DevToolsIntegration.js). While a handler is
|
|
140
|
+
// installed the pointer belongs to DevTools, not to the app: motion, press
|
|
141
|
+
// and release are answered with the node under the pointer and go no
|
|
142
|
+
// further, so picking an element cannot also hover a row, press a button or
|
|
143
|
+
// start a drag. `Escape` cancels. Installed only while the user is actually
|
|
144
|
+
// picking — the crosshair in the DevTools toolbar — so the cost outside
|
|
145
|
+
// that is one null check per event.
|
|
146
|
+
let inspectHandler = null;
|
|
147
|
+
export function setInspectHandler(fn) {
|
|
148
|
+
inspectHandler = fn;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* `createRoot({ restoreFocusOnReveal })`: whether a subtree coming back
|
|
153
|
+
* brings the keyboard back with it (`EventManager.subtreeRevealed`).
|
|
154
|
+
*/
|
|
155
|
+
export function beginFocus(app, option) {
|
|
156
|
+
if (app) app._reactX11RestoreFocus = option !== false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Defaults to on for an app that never went through `createRoot` — a mock,
|
|
160
|
+
* a unit test — the way `composeTableFor` falls back to the built-ins. */
|
|
161
|
+
function restoresFocusOnReveal(app) {
|
|
162
|
+
return app?._reactX11RestoreFocus ?? true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* --- attention (ntk#37) ------------------------------------------------- *
|
|
166
|
+
*
|
|
167
|
+
* Every other event here is *routed*: hit test the pointer, build the
|
|
168
|
+
* ancestor path, walk it. Attention cannot work that way, because the whole
|
|
169
|
+
* point is to reach a node the pointer has **not** arrived at. So it is
|
|
170
|
+
* *matched* instead: each motion event updates a velocity estimate, and every
|
|
171
|
+
* registered candidate is asked "does this trajectory enter you, and how
|
|
172
|
+
* soon". The nearest answer wins. There is no capture, no bubble and no
|
|
173
|
+
* ancestor chain — an ancestor is not on the way to its child in any sense
|
|
174
|
+
* the pointer knows about, and a handler that fired for a descendant it never
|
|
175
|
+
* named would be guessing.
|
|
176
|
+
*
|
|
177
|
+
* Cost is the reason the candidates are a registry rather than a tree walk.
|
|
178
|
+
* A window whose tree contains no `unstable_onAttention` and no
|
|
179
|
+
* `:attention` block
|
|
180
|
+
* runs one `Set.size` read per motion event and nothing else — see
|
|
181
|
+
* `_onMouseMove`.
|
|
182
|
+
*
|
|
183
|
+
* **Deliberately absent from `docs/`.** This is a prototype and the shape may
|
|
184
|
+
* not survive: nothing here has been calibrated against real pointer traces,
|
|
185
|
+
* and the case for the handler rests on there being work worth starting
|
|
186
|
+
* early, which the interaction paths measured so far mostly do not have. It
|
|
187
|
+
* stays out of the documentation so that nothing comes to depend on it before
|
|
188
|
+
* that is settled — `examples/attention.jsx` is the only prose, and it says
|
|
189
|
+
* the same. Removing the feature is a `git revert` of the commit that added
|
|
190
|
+
* it; keep it that way.
|
|
191
|
+
*/
|
|
192
|
+
|
|
193
|
+
/** How many pointer samples the velocity is averaged over. Two is a
|
|
194
|
+
* difference and jitters; a handful smooths a hand without adding lag
|
|
195
|
+
* anything at this timescale can feel. */
|
|
196
|
+
const ATTENTION_SAMPLES = 5;
|
|
197
|
+
|
|
198
|
+
/** Samples older than this are stale — a pointer that stopped and started
|
|
199
|
+
* again must not inherit the direction it had before the pause. */
|
|
200
|
+
const ATTENTION_SAMPLE_MS = 120;
|
|
201
|
+
|
|
202
|
+
/** Below this the pointer is settling rather than travelling (device px per
|
|
203
|
+
* ms; ~50 px/s). Extrapolating a direction from noise this small points
|
|
204
|
+
* attention at whatever happens to be off to one side, so the slow case
|
|
205
|
+
* falls back to "what is under the pointer" instead. */
|
|
206
|
+
const ATTENTION_MIN_SPEED = 0.05;
|
|
207
|
+
|
|
208
|
+
/** How far ahead to look, in milliseconds of travel at the current speed.
|
|
209
|
+
* This is the honest unit for it: the question a warm-up wants answered is
|
|
210
|
+
* "will the user be here soon enough for the work to have paid off", and
|
|
211
|
+
* that is a time, not a distance. Long enough to be worth acting on, short
|
|
212
|
+
* enough that a flick across the window does not nominate everything on the
|
|
213
|
+
* line. */
|
|
214
|
+
const ATTENTION_HORIZON_MS = 250;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* When the ray from `px,py` along `vx,vy` (device px per ms) first enters
|
|
218
|
+
* `rect`, in milliseconds — 0 if it starts inside, null if it never does.
|
|
219
|
+
*
|
|
220
|
+
* Slab method, the standard ray/AABB test. Because the velocity is per
|
|
221
|
+
* millisecond, the parameter that falls out *is* the time to arrival, which
|
|
222
|
+
* is what both the horizon test and `ev.eta` want.
|
|
223
|
+
*/
|
|
224
|
+
function attentionEta(px, py, vx, vy, rect) {
|
|
225
|
+
const x1 = rect.x;
|
|
226
|
+
const x2 = rect.x + rect.width;
|
|
227
|
+
const y1 = rect.y;
|
|
228
|
+
const y2 = rect.y + rect.height;
|
|
229
|
+
if (px >= x1 && px < x2 && py >= y1 && py < y2) return 0;
|
|
230
|
+
let tmin = 0;
|
|
231
|
+
let tmax = Infinity;
|
|
232
|
+
// x slab, then y slab; a zero component means the ray never crosses that
|
|
233
|
+
// axis, so it has to already be within the slab or it misses entirely
|
|
234
|
+
if (vx === 0) {
|
|
235
|
+
if (px < x1 || px >= x2) return null;
|
|
236
|
+
} else {
|
|
237
|
+
const a = (x1 - px) / vx;
|
|
238
|
+
const b = (x2 - px) / vx;
|
|
239
|
+
tmin = Math.max(tmin, Math.min(a, b));
|
|
240
|
+
tmax = Math.min(tmax, Math.max(a, b));
|
|
241
|
+
}
|
|
242
|
+
if (vy === 0) {
|
|
243
|
+
if (py < y1 || py >= y2) return null;
|
|
244
|
+
} else {
|
|
245
|
+
const a = (y1 - py) / vy;
|
|
246
|
+
const b = (y2 - py) / vy;
|
|
247
|
+
tmin = Math.max(tmin, Math.min(a, b));
|
|
248
|
+
tmax = Math.min(tmax, Math.max(a, b));
|
|
249
|
+
}
|
|
250
|
+
if (tmin > tmax) return null;
|
|
251
|
+
return tmin >= 0 ? tmin : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Wrap an ntk event callback for a *discrete* event — one whose response is
|
|
256
|
+
* a single visual state, so there is nothing a frame's wait could coalesce
|
|
257
|
+
* it with. That is everything ntk does not coalesce: mousedown/mouseup,
|
|
258
|
+
* keydown/keyup, focus/blur and WM messages. Motion and the wheel are the
|
|
259
|
+
* opposite case and stay on the paced frame — the hover diffing motion
|
|
260
|
+
* drives, and a scroll whose distance ntk sums over the frame.
|
|
261
|
+
*
|
|
262
|
+
* The response is painted once the *whole* dispatch has unwound — default
|
|
263
|
+
* actions, React's discrete-priority commit, and every invalidation the two
|
|
264
|
+
* produced — rather than after each handler. That ordering is what makes it
|
|
265
|
+
* one paint rather than two: paint after the `:active` flip but before the
|
|
266
|
+
* state update lands, and the frame shows half the response with the other
|
|
267
|
+
* half still waiting on the frame clock.
|
|
268
|
+
*
|
|
269
|
+
* ntk presents a window's dirty backing rects when its event handler
|
|
270
|
+
* returns, so the blit goes out in the same event-loop turn as the press
|
|
271
|
+
* that caused it. `flushPendingFrames` decides *whether* to paint now; see
|
|
272
|
+
* frames.js for the frame gate that keeps a burst from painting ten times.
|
|
273
|
+
*/
|
|
274
|
+
export function discrete(fn) {
|
|
275
|
+
return (ev) => {
|
|
276
|
+
fn(ev);
|
|
277
|
+
flushSyncWork();
|
|
278
|
+
flushPendingFrames();
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Activate a node the way a finger would: the **whole press gesture**
|
|
284
|
+
* through the normal dispatch — capture, bubble, handlers, the
|
|
285
|
+
* discrete-priority commit and the paint that follows it.
|
|
286
|
+
*
|
|
287
|
+
* One function, because there is more than one way to ask for it and they
|
|
288
|
+
* must not drift: an AT's `DoAction("activate")` (atspi.js) and the
|
|
289
|
+
* keyboard's Space/Enter on a focused control (`Node.defaultKeyDown`,
|
|
290
|
+
* nodes.js) both land here, so a control that acts on the *press* — `Select`
|
|
291
|
+
* and `MenuBar` drop their menus on mousedown, the way real menus do — is
|
|
292
|
+
* reached by either, and neither can be the one input route a widget forgot.
|
|
293
|
+
*
|
|
294
|
+
* Handlers only: the coordinate-driven default actions (caret placement,
|
|
295
|
+
* drag arming) stay out, because the centre of a rect is not a place the
|
|
296
|
+
* user chose.
|
|
297
|
+
*
|
|
298
|
+
* `source` is the native event that asked, when there was one — a key press.
|
|
299
|
+
* It carries the modifier mask and the X timestamp across, so Shift+Enter on
|
|
300
|
+
* a control reads as a shift-click and a handler that raises a window has a
|
|
301
|
+
* real time to do it with. An AT activation has neither and passes nothing.
|
|
302
|
+
*/
|
|
303
|
+
export function synthesizeClick(node, rect, source = null) {
|
|
304
|
+
const manager = node?.root?.events;
|
|
305
|
+
if (!manager || node.destroyed) return false;
|
|
306
|
+
const abs = rect ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
307
|
+
const native = {
|
|
308
|
+
x: Math.round(abs.x + abs.width / 2),
|
|
309
|
+
y: Math.round(abs.y + abs.height / 2),
|
|
310
|
+
// the X `state` mask, which is where SyntheticEvent reads shift/ctrl/
|
|
311
|
+
// alt/super from on *every* event, not just keys
|
|
312
|
+
buttons: source?.buttons ?? 0,
|
|
313
|
+
keycode: 1,
|
|
314
|
+
time: source?.time,
|
|
315
|
+
};
|
|
316
|
+
discrete(() => {
|
|
317
|
+
manager.dispatch('MouseDown', node, native, { button: 1, detail: 1 });
|
|
318
|
+
if (!node.destroyed) {
|
|
319
|
+
manager.dispatch('MouseUp', node, native, { button: 1 });
|
|
320
|
+
}
|
|
321
|
+
if (!node.destroyed) {
|
|
322
|
+
manager.dispatch('Click', node, native, { button: 1, detail: 1 });
|
|
323
|
+
}
|
|
324
|
+
})(native);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
|
|
26
328
|
export class EventManager {
|
|
27
329
|
constructor(windowNode) {
|
|
28
330
|
this.node = windowNode;
|
|
331
|
+
// resolved before any window realizes, constant after (src/scale.js)
|
|
332
|
+
this.scale = windowNode?.scale ?? 1;
|
|
29
333
|
this.hoverPath = [];
|
|
30
334
|
this.downNode = null;
|
|
335
|
+
// where a press landed, and how much of that chain still draws `:active`
|
|
336
|
+
// — the second shrinks and grows again as the pointer leaves and returns
|
|
337
|
+
this.downPath = [];
|
|
338
|
+
this.pressPath = [];
|
|
339
|
+
// whether the press's default action ran, which is what the drag and the
|
|
340
|
+
// release that continue the same gesture follow
|
|
341
|
+
this._downDefaulted = false;
|
|
31
342
|
this.capturedNode = null;
|
|
343
|
+
// Attention (ntk#37): the one node the pointer looks like it is heading
|
|
344
|
+
// for, the recent pointer samples the trajectory is estimated from, and
|
|
345
|
+
// the window's candidate registry held directly so the motion path costs
|
|
346
|
+
// one property read to find out there is nothing to do.
|
|
347
|
+
this.attentionNode = null;
|
|
348
|
+
this._attentionSamples = [];
|
|
349
|
+
this._attentionNodes = windowNode?._attentionNodes ?? new Set();
|
|
32
350
|
this.focused = null;
|
|
351
|
+
// the focused node and its ancestors, which is what draws `:focus-within`
|
|
352
|
+
this.focusWithinPath = [];
|
|
33
353
|
// what had focus before `focused`, so a focus scope opened by something
|
|
34
354
|
// that focuses itself still knows where to hand focus back
|
|
35
355
|
this._previousFocus = null;
|
|
36
356
|
// focus scopes, innermost last: [{ node, restore }]
|
|
37
357
|
this.scopes = [];
|
|
358
|
+
// what focus to hand back to a subtree that comes out of hiding, keyed by
|
|
359
|
+
// the node that hid: hidden node → { node, visible } (subtreeHidden)
|
|
360
|
+
this._hiddenFocus = new WeakMap();
|
|
38
361
|
// resolved lazily for popups: the manager that owns focus (focusManager)
|
|
39
362
|
this._focusOwner = null;
|
|
363
|
+
// on a *top-level* window's manager: which of this window's managers
|
|
364
|
+
// took node focus last, and so answers keys addressed to any of them
|
|
365
|
+
// (`_keyManager`). Its own, until a nested `<window>` says otherwise.
|
|
366
|
+
this._focusHolder = null;
|
|
367
|
+
// the dead-key/Compose state machine, built on the first key (undefined
|
|
368
|
+
// until then, null when the app turned composition off)
|
|
369
|
+
this._composerInstance = undefined;
|
|
40
370
|
// whether the X server sends keys to this window at all. Assume yes
|
|
41
371
|
// until told otherwise: ntk < 3.7 never reports focus changes, and a
|
|
42
372
|
// toolkit that believed it was unfocused would blink no caret at all.
|
|
43
373
|
this.windowFocused = true;
|
|
374
|
+
// on a *focus* manager: which of the windows sharing this focus holds
|
|
375
|
+
// the X input focus, when it is not this one — a managed `<popup>` is a
|
|
376
|
+
// window the WM focuses in its own right (`keyboardFocused`). Null until
|
|
377
|
+
// one of them says so, which leaves the answer this window's own.
|
|
378
|
+
this._keyboardWindow = null;
|
|
379
|
+
// what the focused node was last told about the keyboard, so the two
|
|
380
|
+
// windows' focus events settle it once rather than twice
|
|
381
|
+
// (`_syncDefaultFocus`)
|
|
382
|
+
this._defaultFocusOn = false;
|
|
44
383
|
this._lastClick = { time: 0, x: 0, y: 0, detail: 0 };
|
|
384
|
+
// the sub-pixel part of a smooth scroll the default action has not spent
|
|
385
|
+
// yet — see `_onWheel`, which moves whole pixels so the scroll blit stays
|
|
386
|
+
// available
|
|
387
|
+
this._wheelOwed = { x: 0, y: 0 };
|
|
388
|
+
// the window's DragSession while a press has armed it (src/dnd.js)
|
|
389
|
+
this._dragArmed = null;
|
|
390
|
+
// on a *top-level* window's manager: the chords bound anywhere in its
|
|
391
|
+
// tree, newest last (`registerAccelerator`). Null until one is bound,
|
|
392
|
+
// which is the common case and costs one null check per key.
|
|
393
|
+
this._accelerators = null;
|
|
45
394
|
}
|
|
46
395
|
|
|
47
396
|
/**
|
|
@@ -64,15 +413,125 @@ export class EventManager {
|
|
|
64
413
|
return this._focusOwner ?? this;
|
|
65
414
|
}
|
|
66
415
|
|
|
67
|
-
/**
|
|
68
|
-
*
|
|
416
|
+
/**
|
|
417
|
+
* Whether the keyboard is on this focus — which is not the same question
|
|
418
|
+
* as whether it is on this *window*.
|
|
419
|
+
*
|
|
420
|
+
* One node is focused per focus manager, and a manager can span more than
|
|
421
|
+
* one X window: a `<popup>` shares its owner's focus, and a managed one —
|
|
422
|
+
* a `<Dialog>` — is a real window the window manager focuses in its own
|
|
423
|
+
* right. Opening one moves the X focus off the owner and onto the popup,
|
|
424
|
+
* so the owner's `windowFocused` goes false at the very moment the field
|
|
425
|
+
* inside the dialog starts receiving keys. Asking the owner alone is how
|
|
426
|
+
* a focused field ends up with a `:focus` ring and no caret (issue #333).
|
|
427
|
+
*
|
|
428
|
+
* So the answer is the group's: whichever of its windows last took the X
|
|
429
|
+
* focus, or this one while none has said otherwise.
|
|
430
|
+
*/
|
|
431
|
+
get keyboardFocused() {
|
|
432
|
+
const manager = this.focusManager;
|
|
433
|
+
if (manager !== this) return manager.keyboardFocused;
|
|
434
|
+
// a dialog that closed while it held the keyboard leaves a record of a
|
|
435
|
+
// window that is not there any more, and the answer is this one's again
|
|
436
|
+
if (manager._keyboardWindow?.node.destroyed) manager._keyboardWindow = null;
|
|
437
|
+
return (manager._keyboardWindow ?? manager).windowFocused;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Tell the focused node whether the keyboard is actually on it — the
|
|
442
|
+
* `_focused` flag, the caret and its blink timer, which is everything
|
|
443
|
+
* `:focus` does not cover.
|
|
444
|
+
*
|
|
445
|
+
* Driven from here rather than from either window's focus event, because
|
|
446
|
+
* the two arrive in an order nobody chooses: opening a `<Dialog>` blurs
|
|
447
|
+
* the owner *before* it focuses the popup, and only one of those two
|
|
448
|
+
* managers has the focused node. Running it on both, against the group's
|
|
449
|
+
* answer, settles them the same way whichever lands last — and the record
|
|
450
|
+
* of what the node was last told is what keeps a `defaultFocus` from
|
|
451
|
+
* arming a second blink timer over the first.
|
|
452
|
+
*/
|
|
453
|
+
_syncDefaultFocus() {
|
|
454
|
+
const on = this.keyboardFocused;
|
|
455
|
+
if (on === this._defaultFocusOn) return;
|
|
456
|
+
this._defaultFocusOn = on;
|
|
457
|
+
const node = this.focused;
|
|
458
|
+
if (!node || node.destroyed) return;
|
|
459
|
+
if (on) node.defaultFocus?.();
|
|
460
|
+
else node.defaultBlur?.();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The manager of the top-level window this one belongs to.
|
|
465
|
+
*
|
|
466
|
+
* A nested `<window>` is a child X window *inside* a top-level one, and a
|
|
467
|
+
* `<popup>` hangs off it; the X input focus only ever lands on the
|
|
468
|
+
* top-level (or, for a managed popup, on the popup itself, whose keys the
|
|
469
|
+
* owner already answers). So the top-level is the one place where "which
|
|
470
|
+
* of our windows is the keyboard's" can be recorded — see `_keyManager`.
|
|
471
|
+
*/
|
|
472
|
+
get topLevelManager() {
|
|
473
|
+
const manager = this.focusManager;
|
|
474
|
+
const owner = manager.node.parent?.root?.events;
|
|
475
|
+
return owner && owner !== manager ? owner.topLevelManager : manager;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* The manager a key that arrived *here* should be dispatched through.
|
|
480
|
+
*
|
|
481
|
+
* Focus is per `<window>` (docs/events.md), but delivery is not ours to
|
|
482
|
+
* choose: X sends a key to the focus window, or to the descendant of it
|
|
483
|
+
* the pointer happens to be over. With a nested `<window>` those come
|
|
484
|
+
* apart — the field is focused in the inner window's manager while the
|
|
485
|
+
* keyboard belongs to the outer one — and the key used to be dispatched
|
|
486
|
+
* against a manager with no focused node at all, which is a key that
|
|
487
|
+
* types nothing. It also meant *the pointer* decided whether typing
|
|
488
|
+
* worked: park it over the inner window and the same keystroke arrived
|
|
489
|
+
* somewhere else.
|
|
490
|
+
*
|
|
491
|
+
* So the window that holds the focused node answers, whichever of this
|
|
492
|
+
* top-level's windows the key was addressed to. `focus()` records it
|
|
493
|
+
* because only it knows the order they were focused in; a record whose
|
|
494
|
+
* node has since gone is no better than none (issue #331).
|
|
495
|
+
*/
|
|
496
|
+
_keyManager() {
|
|
497
|
+
const manager = this.focusManager;
|
|
498
|
+
const holder = manager.topLevelManager._focusHolder;
|
|
499
|
+
// The record wins over this window's own `focused`, which is not the
|
|
500
|
+
// same question: focus is per window, so a nested `<window>` the user
|
|
501
|
+
// has since left still has a focused node in it. One of them is where
|
|
502
|
+
// the user is typing, and that is the one that took focus last.
|
|
503
|
+
if (
|
|
504
|
+
holder &&
|
|
505
|
+
!holder.node.destroyed &&
|
|
506
|
+
holder.focused &&
|
|
507
|
+
!holder.focused.destroyed
|
|
508
|
+
) {
|
|
509
|
+
return holder;
|
|
510
|
+
}
|
|
511
|
+
return manager;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* DOM-style click counting: repeated presses close enough together in time
|
|
516
|
+
* and space bump `detail` (2 = double click, 3 = triple …).
|
|
517
|
+
*
|
|
518
|
+
* The window and the distance are the desktop's — `Net/DoubleClickTime`
|
|
519
|
+
* and `Net/DoubleClickDistance` — falling back to 400ms/4px where no
|
|
520
|
+
* settings daemon answered. A double click that needs to be faster here
|
|
521
|
+
* than everywhere else on the desktop is a double click people miss.
|
|
522
|
+
*/
|
|
69
523
|
_clickDetail(native) {
|
|
70
524
|
const now = Date.now();
|
|
71
525
|
const last = this._lastClick;
|
|
526
|
+
const { doubleClickMs, doubleClickDistance } = desktopSettings(
|
|
527
|
+
this.node?.app,
|
|
528
|
+
);
|
|
529
|
+
// the desktop's slop is logical pixels; the coordinates are device
|
|
530
|
+
const slop = doubleClickDistance * this.scale;
|
|
72
531
|
const detail =
|
|
73
|
-
now - last.time <
|
|
74
|
-
Math.abs(native.x - last.x) <=
|
|
75
|
-
Math.abs(native.y - last.y) <=
|
|
532
|
+
now - last.time < doubleClickMs &&
|
|
533
|
+
Math.abs(native.x - last.x) <= slop &&
|
|
534
|
+
Math.abs(native.y - last.y) <= slop
|
|
76
535
|
? last.detail + 1
|
|
77
536
|
: 1;
|
|
78
537
|
this._lastClick = { time: now, x: native.x, y: native.y, detail };
|
|
@@ -82,16 +541,39 @@ export class EventManager {
|
|
|
82
541
|
attach() {
|
|
83
542
|
const wnd = this.node.window;
|
|
84
543
|
if (typeof wnd.on !== 'function') return;
|
|
85
|
-
|
|
86
|
-
|
|
544
|
+
// `onDiscrete` paints the response from the handler; plain `wnd.on`
|
|
545
|
+
// leaves it to the paced frame. Which is which is not a judgement call:
|
|
546
|
+
// it is ntk's coalesce table (lib/events_map.js), and motion is the
|
|
547
|
+
// whole reason that table exists.
|
|
548
|
+
// Every X input event carries a server timestamp, and the selection
|
|
549
|
+
// operations further down need one (inputtime.js). Noting it here, on
|
|
550
|
+
// the way past, is what keeps `write()`/`read()` from having to ask the
|
|
551
|
+
// server for the time they should already know.
|
|
552
|
+
const onDiscrete = (name, fn) =>
|
|
553
|
+
wnd.on(
|
|
554
|
+
name,
|
|
555
|
+
discrete((ev) => {
|
|
556
|
+
noteInputTime(this.node.app, name, ev);
|
|
557
|
+
return fn(ev);
|
|
558
|
+
}),
|
|
559
|
+
);
|
|
560
|
+
onDiscrete('mousedown', (ev) => this._onMouseDown(ev));
|
|
561
|
+
onDiscrete('mouseup', (ev) => this._onMouseUp(ev));
|
|
562
|
+
// ntk's own event, derived from the wheel buttons or from XI2's scroll
|
|
563
|
+
// valuators (ntk >= 7.5.0) — one shape whichever the connection turned
|
|
564
|
+
// out to have. Paced rather than discrete because ntk coalesces it, and
|
|
565
|
+
// it coalesces by *adding up*: a touchpad reports a scroll dozens of
|
|
566
|
+
// times a frame, and the frame's event carries the sum of them rather
|
|
567
|
+
// than the last one, so pacing costs distance nothing.
|
|
568
|
+
wnd.on('wheel', (ev) => this._onWheel(ev));
|
|
87
569
|
wnd.on('mousemove', (ev) => this._onMouseMove(ev));
|
|
88
570
|
wnd.on('mouseout', (ev) => this._onMouseOut(ev));
|
|
89
|
-
|
|
90
|
-
|
|
571
|
+
onDiscrete('keydown', (ev) => this._onKey('KeyDown', ev));
|
|
572
|
+
onDiscrete('keyup', (ev) => this._onKey('KeyUp', ev));
|
|
91
573
|
// window-level focus (ntk >= 3.7.0): the window manager decides which
|
|
92
574
|
// window gets keys, and the focused node's caret/ring has to follow
|
|
93
|
-
|
|
94
|
-
|
|
575
|
+
onDiscrete('focus', (ev) => this._onWindowFocus(true, ev));
|
|
576
|
+
onDiscrete('blur', (ev) => this._onWindowFocus(false, ev));
|
|
95
577
|
}
|
|
96
578
|
|
|
97
579
|
/**
|
|
@@ -101,20 +583,35 @@ export class EventManager {
|
|
|
101
583
|
* suspended, and `<window onFocus/onBlur>` gets told.
|
|
102
584
|
*/
|
|
103
585
|
_onWindowFocus(focused, native) {
|
|
104
|
-
|
|
586
|
+
const changed = this.windowFocused !== focused;
|
|
105
587
|
this.windowFocused = focused;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
588
|
+
// Recorded even when nothing changed here. A window assumes it has the
|
|
589
|
+
// keyboard until told otherwise (`windowFocused`), so the FocusIn that
|
|
590
|
+
// actually hands a managed `<popup>` the keyboard is a no-op *for the
|
|
591
|
+
// popup* — while the manager holding the focused node is another one
|
|
592
|
+
// again, and has just been told the keyboard left (`keyboardFocused`).
|
|
593
|
+
const manager = this.focusManager;
|
|
594
|
+
if (focused) manager._keyboardWindow = this;
|
|
595
|
+
else if (manager._keyboardWindow === this) manager._keyboardWindow = null;
|
|
596
|
+
// a half-typed accent does not wait for the user to come back: the keys
|
|
597
|
+
// that would finish it are going somewhere else now
|
|
598
|
+
if (changed && !focused) this._endComposition(native);
|
|
599
|
+
// …and the focused node hears the focus group's answer, not this
|
|
600
|
+
// window's, whichever of them the X focus just moved between
|
|
601
|
+
manager._syncDefaultFocus();
|
|
602
|
+
if (!changed) return;
|
|
603
|
+
// …and the things that keep a window of their own open on the strength
|
|
604
|
+
// of this one having focus — a menu, a dropdown — which the focused node
|
|
605
|
+
// keeping its focus would otherwise never tell (`WindowNode`, nodes.js)
|
|
606
|
+
this.node._notifyWindowFocus?.(focused);
|
|
607
|
+
a11yHooks.windowFocus?.(this.node, focused);
|
|
111
608
|
runWithPriority(DiscreteEventPriority, () => {
|
|
112
609
|
const prop = focused ? 'onFocus' : 'onBlur';
|
|
113
610
|
this.node.props[prop]?.(
|
|
114
611
|
this._makeEvent(focused ? 'focus' : 'blur', native, this.node),
|
|
115
612
|
);
|
|
116
613
|
});
|
|
117
|
-
this.node.invalidate(false);
|
|
614
|
+
this.node.invalidate(false, null, 'focus');
|
|
118
615
|
}
|
|
119
616
|
|
|
120
617
|
_public(node) {
|
|
@@ -132,73 +629,43 @@ export class EventManager {
|
|
|
132
629
|
n;
|
|
133
630
|
n = n === this.node ? null : (n.parent ?? this.node)
|
|
134
631
|
) {
|
|
135
|
-
path.
|
|
632
|
+
path.push(n);
|
|
136
633
|
if (n === this.node) break;
|
|
137
634
|
}
|
|
138
|
-
return path;
|
|
635
|
+
return path.reverse();
|
|
139
636
|
}
|
|
140
637
|
|
|
141
638
|
_makeEvent(type, native, target, extra) {
|
|
142
|
-
|
|
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;
|
|
639
|
+
return new SyntheticEvent(this, type, native, target, extra);
|
|
178
640
|
}
|
|
179
641
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
642
|
+
/**
|
|
643
|
+
* Capture → target → bubble along the ancestor path. A caller that
|
|
644
|
+
* already built the target's path for its own bookkeeping passes it in;
|
|
645
|
+
* everyone else lets the default build it. Returns the event.
|
|
646
|
+
*/
|
|
647
|
+
dispatch(name, target, native, extra, path = this._path(target)) {
|
|
648
|
+
const ev = this._makeEvent(eventType(name), native, target, extra);
|
|
649
|
+
// the two handler keys are per dispatch, not per node visited
|
|
650
|
+
const bubbleKey = 'on' + name;
|
|
651
|
+
const captureKey = bubbleKey + 'Capture';
|
|
652
|
+
// Each handler is called inside callHandler: a throw here has no React
|
|
653
|
+
// on the stack to catch it, so bare it would unwind into ntk's socket
|
|
654
|
+
// handler and take the process. Reported and stepped over instead —
|
|
655
|
+
// one bad handler must not stop the ones after it, or the frame loop.
|
|
189
656
|
for (const n of path) {
|
|
190
|
-
const handler = n.props[
|
|
657
|
+
const handler = n.props[captureKey];
|
|
191
658
|
if (handler) {
|
|
192
659
|
ev.currentTarget = this._public(n);
|
|
193
|
-
handler
|
|
660
|
+
callHandler(n, captureKey, handler, ev);
|
|
194
661
|
if (ev.propagationStopped) return ev;
|
|
195
662
|
}
|
|
196
663
|
}
|
|
197
664
|
for (let i = path.length - 1; i >= 0; i--) {
|
|
198
|
-
const handler = path[i].props[
|
|
665
|
+
const handler = path[i].props[bubbleKey];
|
|
199
666
|
if (handler) {
|
|
200
667
|
ev.currentTarget = this._public(path[i]);
|
|
201
|
-
handler
|
|
668
|
+
callHandler(path[i], bubbleKey, handler, ev);
|
|
202
669
|
if (ev.propagationStopped) return ev;
|
|
203
670
|
}
|
|
204
671
|
}
|
|
@@ -218,63 +685,226 @@ export class EventManager {
|
|
|
218
685
|
);
|
|
219
686
|
}
|
|
220
687
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
688
|
+
/**
|
|
689
|
+
* Answer an input the grab brought in from outside the window with the
|
|
690
|
+
* dismissal it is, and say so. Both the press and the wheel end here: a
|
|
691
|
+
* menu is anchored to something, and scrolling that something away is as
|
|
692
|
+
* much "I am doing something else now" as clicking beside it.
|
|
693
|
+
*/
|
|
694
|
+
_dismissOutside(native) {
|
|
695
|
+
if (!this._pressOutside(native)) return false;
|
|
696
|
+
runWithPriority(DiscreteEventPriority, () => {
|
|
697
|
+
const onDismiss = this.node.props.onDismiss;
|
|
698
|
+
if (onDismiss) {
|
|
699
|
+
callHandler(
|
|
700
|
+
this.node,
|
|
701
|
+
'onDismiss',
|
|
702
|
+
onDismiss,
|
|
229
703
|
this._makeEvent('dismiss', native, this.node),
|
|
230
704
|
);
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* A scroll: ntk's `wheel`, in notches, whatever measured it.
|
|
712
|
+
*
|
|
713
|
+
* On a connection with XI2 that is the scroll valuators of the device the
|
|
714
|
+
* user actually touched, so a touchpad's two-finger scroll arrives as the
|
|
715
|
+
* fractions of a notch it really was — and arrives at all, where the
|
|
716
|
+
* emulated button 4/5 it also sends could only ever say "one notch, again".
|
|
717
|
+
* Everywhere else it is that button, worth exactly one notch, which is the
|
|
718
|
+
* most a press can carry. Neither is named below: the difference is the
|
|
719
|
+
* value of `deltaY`, and `ev.smooth` for a handler that wants to know
|
|
720
|
+
* whether the device it is reading can do better than whole notches.
|
|
721
|
+
*
|
|
722
|
+
* Continuous priority, as in the DOM: a scroll is a stream of states on the
|
|
723
|
+
* way somewhere rather than one, and React must be free to interrupt the
|
|
724
|
+
* render it started for the notch before.
|
|
725
|
+
*/
|
|
726
|
+
_onWheel(native) {
|
|
727
|
+
// The first wheel is what says this window wants smooth scrolling. It was
|
|
728
|
+
// created on core events — an XI2 selection costs four times as many
|
|
729
|
+
// bytes per *pointer move*, which a window that is never scrolled would
|
|
730
|
+
// pay for nothing — and takes the selection here, once (nodes.js,
|
|
731
|
+
// `upgradeToXI2`). Ahead of the dismiss check, because a scroll this
|
|
732
|
+
// window heard is a scroll this window heard whatever it does with it.
|
|
733
|
+
this.node.upgradeToXI2?.();
|
|
734
|
+
// A scroll somewhere else is an interaction somewhere else: the pointer
|
|
735
|
+
// grab an open menu holds brings it here, and the menu is anchored to
|
|
736
|
+
// content that is about to move out from under it. Same answer the press
|
|
737
|
+
// outside gets, and for the same reason (`_pressOutside`).
|
|
738
|
+
if (this._dismissOutside(native)) return;
|
|
739
|
+
runWithPriority(ContinuousEventPriority, () => {
|
|
740
|
+
const target = this._hit(native);
|
|
741
|
+
// Shift turns a vertical wheel sideways — the convention for the mouse
|
|
742
|
+
// and the touchpad that have no horizontal axis. Read off the delta
|
|
743
|
+
// rather than off the source: a plain wheel mouse on an XI2 connection
|
|
744
|
+
// reports through the valuators too, and it still has only one axis.
|
|
745
|
+
const sideways = Boolean(native.buttons & 1) && !native.deltaX;
|
|
746
|
+
const notchX = sideways ? native.deltaY : native.deltaX;
|
|
747
|
+
const notchY = sideways ? 0 : native.deltaY;
|
|
748
|
+
const ev = this.dispatch('Wheel', target, native, {
|
|
749
|
+
deltaX: notchX * WHEEL_NOTCH_PX,
|
|
750
|
+
deltaY: notchY * WHEEL_NOTCH_PX,
|
|
751
|
+
// whether the delta can be a fraction of a notch, not whether this
|
|
752
|
+
// one is: a touchpad that happens to have travelled exactly one
|
|
753
|
+
// notch this frame is still the device that can travel a third
|
|
754
|
+
smooth: Boolean(native.smooth),
|
|
231
755
|
});
|
|
756
|
+
if (ev.defaultPrevented) return;
|
|
757
|
+
// The element's own wheel, ahead of the scroll chain and in its own
|
|
758
|
+
// shape: the whole event, at the node the pointer is over. A graph
|
|
759
|
+
// pane's wheel is a zoom about the pointer, which needs the point that
|
|
760
|
+
// must not move (`ev.x`/`ev.y`) and the modifiers that say zoom from
|
|
761
|
+
// pan — neither of which a protocol handing out deltas can carry — and
|
|
762
|
+
// it answers the gesture whether or not anything "can scroll". An
|
|
763
|
+
// element whose wheel *is* a scroll stays on `canScroll`/`scrollBy`
|
|
764
|
+
// below; this is for the ones whose wheel is not (issue #302).
|
|
765
|
+
//
|
|
766
|
+
// It reads the delta before the truncation, fractions and all: whole
|
|
767
|
+
// pixels are the scroll blit's business, and a zoom factor is
|
|
768
|
+
// continuous.
|
|
769
|
+
target.defaultWheel?.(ev);
|
|
770
|
+
// …and consuming it ends the event here, so the chain never runs. Same
|
|
771
|
+
// word one layer down as everywhere else in the seam: what it prevents
|
|
772
|
+
// is the default action left after this one.
|
|
773
|
+
if (ev.defaultPrevented) return;
|
|
774
|
+
// **The default action moves whole pixels and keeps the change.** A
|
|
775
|
+
// scroll offset that is not an integer costs the scroll blit — the
|
|
776
|
+
// server-side copy that makes a scroll cheap can only shift by whole
|
|
777
|
+
// pixels, so `_applyScrollBlits` (nodes.js) declines a fractional one
|
|
778
|
+
// and repaints the viewport instead. A touchpad reporting a third of a
|
|
779
|
+
// notch would therefore turn every frame of the smoothest gesture the
|
|
780
|
+
// renderer has into a full repaint, which is the opposite of the
|
|
781
|
+
// trade. The fraction is not dropped, it is carried to the next event,
|
|
782
|
+
// so a slow scroll still moves — it moves a pixel at a time.
|
|
783
|
+
// `ev.deltaX/Y` are logical (what handlers read); the scroll they
|
|
784
|
+
// become moves device pixels, and truncating *after* the multiply is
|
|
785
|
+
// what keeps the blit on whole device pixels at fractional scales.
|
|
786
|
+
const owedX = this._wheelOwed.x + ev.deltaX * this.scale;
|
|
787
|
+
const owedY = this._wheelOwed.y + ev.deltaY * this.scale;
|
|
788
|
+
const dx = Math.trunc(owedX);
|
|
789
|
+
const dy = Math.trunc(owedY);
|
|
790
|
+
this._wheelOwed = { x: owedX - dx, y: owedY - dy };
|
|
791
|
+
if (dx === 0 && dy === 0) return;
|
|
792
|
+
// The nearest node out from the target that says it has somewhere to
|
|
793
|
+
// go on this axis scrolls. Chaining past one that fits its own content
|
|
794
|
+
// is what a browser does, and it matters now that any `<box>` can be a
|
|
795
|
+
// scroll container — a pane that happens to fit must not swallow the
|
|
796
|
+
// wheel the window would have answered. The `<window>` is the last
|
|
797
|
+
// candidate, then the walk stops.
|
|
798
|
+
//
|
|
799
|
+
// Two methods and no kinds: `<textarea>` scrolls pixels it painted
|
|
800
|
+
// rather than children it laid out, and so does a registered
|
|
801
|
+
// element that draws its own content, and neither of them should
|
|
802
|
+
// have to be named here to be reachable (issue #253).
|
|
803
|
+
for (let n = target; n; n = n.parent) {
|
|
804
|
+
if (n.canScroll?.(dx, dy)) {
|
|
805
|
+
// built-in scrollers take the device delta whole; a registered
|
|
806
|
+
// element's own scrollBy speaks the public (logical) unit
|
|
807
|
+
if (n._scrollByDevice) n._scrollByDevice(dx, dy);
|
|
808
|
+
else n.scrollBy({ x: dx / this.scale, y: dy / this.scale });
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
if (n === this.node) break;
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
_onMouseDown(native) {
|
|
817
|
+
// the wheel arrived as `wheel`, with a distance the press cannot carry
|
|
818
|
+
if (WHEEL_BUTTONS.has(native.keycode)) return;
|
|
819
|
+
if (inspectHandler) {
|
|
820
|
+
inspectHandler('select', this._hit(native), native);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (clickToComponentHandler && Boolean(native.buttons & MOD.Alt)) {
|
|
824
|
+
clickToComponentHandler(this._hit(native), native);
|
|
232
825
|
return;
|
|
233
826
|
}
|
|
827
|
+
if (this._dismissOutside(native)) return;
|
|
234
828
|
runWithPriority(DiscreteEventPriority, () => {
|
|
235
|
-
const wheel = WHEEL_BUTTONS[native.keycode];
|
|
236
829
|
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
830
|
this.downNode = target;
|
|
831
|
+
this.downPath = target ? this._path(target) : [];
|
|
832
|
+
// the caret is about to move, and the accent was aimed at where it was
|
|
833
|
+
this._endComposition(native);
|
|
834
|
+
this._setPressed(this.downPath);
|
|
255
835
|
this._focusFromPress(target);
|
|
256
|
-
const ev = this.dispatch(
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
836
|
+
const ev = this.dispatch(
|
|
837
|
+
'MouseDown',
|
|
838
|
+
target,
|
|
839
|
+
native,
|
|
840
|
+
{
|
|
841
|
+
button: native.keycode,
|
|
842
|
+
detail: this._clickDetail(native),
|
|
843
|
+
},
|
|
844
|
+
this.downPath,
|
|
845
|
+
);
|
|
846
|
+
// A gesture is vetoed at its press, once: the drag and the release
|
|
847
|
+
// that continue it are the same gesture, so an element whose
|
|
848
|
+
// `defaultMouseDown` never ran does not then get told about motion it
|
|
849
|
+
// has no press behind. `<textinput>` guarded that itself with a
|
|
850
|
+
// `_dragging` flag; a registered element should not have to rediscover
|
|
851
|
+
// the need for one (issue #251).
|
|
852
|
+
this._downDefaulted = !ev.defaultPrevented;
|
|
853
|
+
if (this._downDefaulted) {
|
|
854
|
+
target.defaultMouseDown?.(ev);
|
|
855
|
+
}
|
|
856
|
+
// a left press on (or inside) a `draggable` arms a drag; below the
|
|
857
|
+
// threshold the gesture is still a click (src/dnd.js)
|
|
858
|
+
if (native.keycode === 1 && !ev.defaultPrevented) {
|
|
859
|
+
this._dragArmed = armDrag(this.node, target, native);
|
|
860
|
+
}
|
|
861
|
+
// Right-click is two events, as in the DOM: mousedown, then a
|
|
862
|
+
// separate contextmenu whose default action opens the element's own
|
|
863
|
+
// menu. Handlers that only want to suppress the menu can do it
|
|
864
|
+
// without also giving up the caret placement mousedown just did.
|
|
865
|
+
if (native.keycode === RIGHT_BUTTON) {
|
|
866
|
+
const menuEv = this.dispatch('ContextMenu', target, native, {
|
|
867
|
+
button: native.keycode,
|
|
868
|
+
});
|
|
869
|
+
if (!menuEv.defaultPrevented) {
|
|
870
|
+
target?.defaultContextMenu?.(menuEv);
|
|
871
|
+
}
|
|
262
872
|
}
|
|
263
873
|
});
|
|
264
874
|
}
|
|
265
875
|
|
|
266
876
|
_onMouseUp(native) {
|
|
267
|
-
if (WHEEL_BUTTONS
|
|
877
|
+
if (WHEEL_BUTTONS.has(native.keycode)) return; // wheel release
|
|
878
|
+
// the press that picked an element never reached the app; its release
|
|
879
|
+
// must not either, or a control sees a mouseup it was never pressed for
|
|
880
|
+
if (inspectHandler) return;
|
|
268
881
|
runWithPriority(DiscreteEventPriority, () => {
|
|
882
|
+
// a completed drag ends the gesture: no mouseup, no click — as in
|
|
883
|
+
// the DOM, where dragend replaces them
|
|
884
|
+
const drag = this._dragArmed;
|
|
885
|
+
if (drag) {
|
|
886
|
+
this._dragArmed = null;
|
|
887
|
+
if (drag.release(native)) {
|
|
888
|
+
this._clearPress();
|
|
889
|
+
this.downNode = null;
|
|
890
|
+
this.capturedNode = null;
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
269
894
|
const captured = this._captured();
|
|
270
895
|
const target = captured ?? this._hit(native);
|
|
271
896
|
const ev = this.dispatch('MouseUp', target, native, {
|
|
272
897
|
button: native.keycode,
|
|
898
|
+
// the count the press was given: a release and the click synthesized
|
|
899
|
+
// from it are the *same* multi-click as the mousedown that opened it,
|
|
900
|
+
// and `onClick` is where a double click is actually handled
|
|
901
|
+
detail: this._lastClick.detail,
|
|
273
902
|
});
|
|
274
903
|
// capture ends with the gesture, like implicit DOM pointer capture
|
|
275
904
|
this.capturedNode = null;
|
|
276
|
-
|
|
277
|
-
|
|
905
|
+
this._clearPress();
|
|
906
|
+
if (this._downDefaulted && this.downNode && !this.downNode.destroyed) {
|
|
907
|
+
this.downNode.defaultMouseUp?.(ev);
|
|
278
908
|
}
|
|
279
909
|
if (this.downNode) {
|
|
280
910
|
// click fires on the nearest common ancestor of press and release
|
|
@@ -288,6 +918,7 @@ export class EventManager {
|
|
|
288
918
|
if (clickTarget) {
|
|
289
919
|
this.dispatch('Click', clickTarget, native, {
|
|
290
920
|
button: native.keycode,
|
|
921
|
+
detail: this._lastClick.detail,
|
|
291
922
|
});
|
|
292
923
|
}
|
|
293
924
|
this.downNode = null;
|
|
@@ -296,17 +927,47 @@ export class EventManager {
|
|
|
296
927
|
}
|
|
297
928
|
|
|
298
929
|
_onMouseMove(native) {
|
|
930
|
+
if (inspectHandler) {
|
|
931
|
+
inspectHandler('move', this._hit(native), native);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
299
934
|
runWithPriority(ContinuousEventPriority, () => {
|
|
935
|
+
// an active drag owns the pointer: drag-path diffing replaces hover,
|
|
936
|
+
// onDrag replaces mousemove. Below the threshold this falls through.
|
|
937
|
+
const drag = this._dragArmed;
|
|
938
|
+
if (drag && drag.motion(native)) return;
|
|
300
939
|
const captured = this._captured();
|
|
301
940
|
const target = captured ?? this._hit(native);
|
|
941
|
+
const path = this._path(target);
|
|
302
942
|
// while captured, hover stays put: dragging a slider must not light
|
|
303
943
|
// up every widget the pointer crosses
|
|
304
|
-
this._updateHover(captured ? this.hoverPath :
|
|
305
|
-
|
|
306
|
-
//
|
|
307
|
-
if (this.downNode && !
|
|
308
|
-
this.
|
|
944
|
+
this._updateHover(captured ? this.hoverPath : path, native);
|
|
945
|
+
// and neither does the press — the gesture owns the pointer, so a
|
|
946
|
+
// captured control stays pressed wherever the pointer wandered off to
|
|
947
|
+
if (this.downNode && !captured)
|
|
948
|
+
this._setPressed(this._pressedAlong(path));
|
|
949
|
+
const ev = this.dispatch('MouseMove', target, native, undefined, path);
|
|
950
|
+
// Hover motion, for an element that paints its own hover state — the
|
|
951
|
+
// node, the edge or the handle under the pointer lights up. Core
|
|
952
|
+
// already computes the path this needs, once per motion, for `:hover`
|
|
953
|
+
// and `onMouseEnter`/`Leave`; the element could not hear it (#302).
|
|
954
|
+
//
|
|
955
|
+
// Not while a capture holds the pointer: hover is deliberately frozen
|
|
956
|
+
// for the length of a gesture (see `_updateHover` above), and the
|
|
957
|
+
// motion of a gesture is `defaultMouseDrag`'s to deliver.
|
|
958
|
+
if (!captured && !ev.defaultPrevented) target.defaultMouseMove?.(ev);
|
|
959
|
+
// drags deliver to the pressed node even when the pointer leaves it —
|
|
960
|
+
// unless the press that started them was vetoed, see `_downDefaulted`
|
|
961
|
+
if (this._downDefaulted && this.downNode && !this.downNode.destroyed) {
|
|
962
|
+
this.downNode.defaultMouseDrag?.(ev);
|
|
309
963
|
}
|
|
964
|
+
// Attention, last (ntk#37). Two reasons for the position. It is
|
|
965
|
+
// speculative work, and "answer the input, not the outcome" says the
|
|
966
|
+
// real response to this motion — the hover repaint, the drag — goes out
|
|
967
|
+
// before anything done on a guess about the next one. And this line is
|
|
968
|
+
// the whole cost of the feature for a tree that never asked for it: one
|
|
969
|
+
// `size` read on a set the window built empty, no walk, no allocation.
|
|
970
|
+
if (this._attentionNodes.size !== 0) this._updateAttention(native);
|
|
310
971
|
});
|
|
311
972
|
}
|
|
312
973
|
|
|
@@ -332,54 +993,215 @@ export class EventManager {
|
|
|
332
993
|
// focus alone: the press never reached that window, and menus rely on it
|
|
333
994
|
// (their rows are not focusable, the trigger keeps the keys)
|
|
334
995
|
if (!focusable && manager !== this) return;
|
|
335
|
-
manager.focus(focusable ?? null);
|
|
996
|
+
manager.focus(focusable ?? null, 'pointer');
|
|
336
997
|
}
|
|
337
998
|
|
|
338
999
|
/** Focusable: `focusable`, an explicit `tabIndex` (including a negative
|
|
339
1000
|
* one, focusable but not tabbable), or a kind that is focusable by default
|
|
340
|
-
* (`<textinput>`). `focusable={false}` and `disabled` opt back out.
|
|
1001
|
+
* (`<textinput>`). `focusable={false}` and `disabled` opt back out. The
|
|
1002
|
+
* rule itself lives in a11y.js, shared with the focus ring and the
|
|
1003
|
+
* AT-SPI FOCUSABLE state. */
|
|
341
1004
|
_isFocusable(node) {
|
|
342
|
-
|
|
343
|
-
return (
|
|
344
|
-
node.props.focusable ??
|
|
345
|
-
(node.props.tabIndex != null ? true : (node.focusableByDefault ?? false))
|
|
346
|
-
);
|
|
1005
|
+
return isFocusable(node);
|
|
347
1006
|
}
|
|
348
1007
|
|
|
349
1008
|
_onMouseOut(native) {
|
|
350
1009
|
runWithPriority(ContinuousEventPriority, () => {
|
|
351
1010
|
this._updateHover([], native);
|
|
1011
|
+
// the pointer is somewhere else entirely: whatever it was heading for
|
|
1012
|
+
// in here, it is not heading for it now
|
|
1013
|
+
if (this._attentionNodes.size !== 0) {
|
|
1014
|
+
this._attentionSamples.length = 0;
|
|
1015
|
+
this._setAttention(null, native);
|
|
1016
|
+
}
|
|
1017
|
+
// the pointer left the window with a button still down: a release out
|
|
1018
|
+
// there synthesizes its click on the window, so that is as much of the
|
|
1019
|
+
// press chain as may still look pressed
|
|
1020
|
+
if (this.downNode) this._setPressed(this._pressedAlong([this.node]));
|
|
352
1021
|
this.node.props.onMouseOut?.(
|
|
353
1022
|
this._makeEvent('mouseOut', native, this.node),
|
|
354
1023
|
);
|
|
355
1024
|
});
|
|
356
1025
|
}
|
|
357
1026
|
|
|
1027
|
+
/**
|
|
1028
|
+
* The chain of the press that is still live, as far along it as the
|
|
1029
|
+
* pointer has stayed. `downPath` is where the press landed and does not
|
|
1030
|
+
* move; this is the part of it a release would still deliver a click to.
|
|
1031
|
+
*/
|
|
1032
|
+
_pressedAlong(path) {
|
|
1033
|
+
return this.downPath.slice(0, sharedPrefix(this.downPath, path));
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** The gesture is over: nothing is pressed, and there is no chain left to
|
|
1037
|
+
* come back to. */
|
|
1038
|
+
_clearPress() {
|
|
1039
|
+
this._setPressed([]);
|
|
1040
|
+
this.downPath = [];
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Flip `:active` over a press chain, diffed the way hover is.
|
|
1045
|
+
*
|
|
1046
|
+
* Two things this is not. It is **not one node**: a press marks the whole
|
|
1047
|
+
* ancestor chain, because the node actually hit is whatever the control
|
|
1048
|
+
* happens to be built out of — a `<Button>`'s label, a `<Switch>`'s thumb —
|
|
1049
|
+
* and a control that draws its own pressed state has no other way to hear
|
|
1050
|
+
* about a press that landed on its own child.
|
|
1051
|
+
*
|
|
1052
|
+
* And it is **not fixed for the gesture**: it narrows as the pointer leaves
|
|
1053
|
+
* the chain and grows back as it returns, so `:active` says "releasing now
|
|
1054
|
+
* activates this" rather than "this is where the press started". That is
|
|
1055
|
+
* the same nearest-common-ancestor rule `_onMouseUp` synthesizes the click
|
|
1056
|
+
* on, which is what keeps the drawing from promising an activation the
|
|
1057
|
+
* release will not deliver.
|
|
1058
|
+
*/
|
|
1059
|
+
_setPressed(path) {
|
|
1060
|
+
const oldPath = this.pressPath;
|
|
1061
|
+
const common = sharedPrefix(oldPath, path);
|
|
1062
|
+
for (let i = oldPath.length - 1; i >= common; i--) {
|
|
1063
|
+
if (!oldPath[i].destroyed) oldPath[i].setStyleState(':active', false);
|
|
1064
|
+
}
|
|
1065
|
+
for (let i = common; i < path.length; i++) {
|
|
1066
|
+
if (!path[i].destroyed) path[i].setStyleState(':active', true);
|
|
1067
|
+
}
|
|
1068
|
+
this.pressPath = path;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
358
1071
|
/** enter/leave do not propagate: each node on the diff gets its own call. */
|
|
359
1072
|
_updateHover(newPath, native) {
|
|
360
1073
|
const oldPath = this.hoverPath;
|
|
361
|
-
|
|
362
|
-
while (
|
|
363
|
-
common < oldPath.length &&
|
|
364
|
-
common < newPath.length &&
|
|
365
|
-
oldPath[common] === newPath[common]
|
|
366
|
-
) {
|
|
367
|
-
common++;
|
|
368
|
-
}
|
|
1074
|
+
const common = sharedPrefix(oldPath, newPath);
|
|
369
1075
|
for (let i = oldPath.length - 1; i >= common; i--) {
|
|
370
1076
|
const n = oldPath[i];
|
|
371
|
-
if (
|
|
372
|
-
|
|
373
|
-
|
|
1077
|
+
if (n.destroyed) continue;
|
|
1078
|
+
// the hover path is the ancestor chain, so a `:hover` block on a
|
|
1079
|
+
// parent lights up while a child is hovered — CSS semantics, for
|
|
1080
|
+
// free, because the path is already computed for enter/leave
|
|
1081
|
+
n.setStyleState(':hover', false);
|
|
1082
|
+
const handler = n.props.onMouseLeave;
|
|
1083
|
+
// the event is built only for a node with somewhere to deliver it:
|
|
1084
|
+
// this loop runs per motion, over a whole ancestor chain (issue #188)
|
|
1085
|
+
if (!handler && !n.defaultMouseLeave) continue;
|
|
1086
|
+
const ev = this._makeEvent('mouseLeave', native, n);
|
|
1087
|
+
handler?.(ev);
|
|
1088
|
+
// …and the element clears the hover state it painted itself, after
|
|
1089
|
+
// the application handler and vetoed by it, like the rest of the seam
|
|
1090
|
+
if (!ev.defaultPrevented) n.defaultMouseLeave?.(ev);
|
|
374
1091
|
}
|
|
375
1092
|
for (let i = common; i < newPath.length; i++) {
|
|
376
1093
|
const n = newPath[i];
|
|
1094
|
+
n.setStyleState(':hover', true);
|
|
377
1095
|
n.props.onMouseEnter?.(this._makeEvent('mouseEnter', native, n));
|
|
378
1096
|
}
|
|
379
1097
|
this.hoverPath = newPath;
|
|
380
1098
|
this._updateCursor(newPath);
|
|
381
1099
|
}
|
|
382
1100
|
|
|
1101
|
+
/**
|
|
1102
|
+
* Who is the pointer heading for? Runs only when the window has
|
|
1103
|
+
* candidates — see the guard in `_onMouseMove`.
|
|
1104
|
+
*
|
|
1105
|
+
* Three steps, and the middle one is the whole idea. Sample the pointer to
|
|
1106
|
+
* get a velocity; ask every candidate when this trajectory would enter it;
|
|
1107
|
+
* give attention to the soonest answer inside the horizon. Nothing is hit
|
|
1108
|
+
* tested and nothing is walked: a candidate wins by being *ahead*, which is
|
|
1109
|
+
* exactly the thing a hit test cannot tell you.
|
|
1110
|
+
*
|
|
1111
|
+
* Below `ATTENTION_MIN_SPEED` there is no trajectory worth extrapolating —
|
|
1112
|
+
* a resting hand jitters a pixel or two and its "direction" is noise — so
|
|
1113
|
+
* the slow case degrades to the candidate under the pointer, which is both
|
|
1114
|
+
* the honest answer and the one that keeps attention from flickering around
|
|
1115
|
+
* the room while somebody reads.
|
|
1116
|
+
*/
|
|
1117
|
+
_updateAttention(native) {
|
|
1118
|
+
const now = native?.time ?? Date.now();
|
|
1119
|
+
const samples = this._attentionSamples;
|
|
1120
|
+
samples.push({ x: native.x, y: native.y, t: now });
|
|
1121
|
+
// drop what is too old to describe the movement happening now, and cap
|
|
1122
|
+
// the window: this array is touched at motion rate and must not grow
|
|
1123
|
+
while (
|
|
1124
|
+
samples.length > ATTENTION_SAMPLES ||
|
|
1125
|
+
(samples.length > 1 && now - samples[0].t > ATTENTION_SAMPLE_MS)
|
|
1126
|
+
) {
|
|
1127
|
+
samples.shift();
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const first = samples[0];
|
|
1131
|
+
const dt = now - first.t;
|
|
1132
|
+
let vx = 0;
|
|
1133
|
+
let vy = 0;
|
|
1134
|
+
if (samples.length > 1 && dt > 0) {
|
|
1135
|
+
vx = (native.x - first.x) / dt;
|
|
1136
|
+
vy = (native.y - first.y) / dt;
|
|
1137
|
+
}
|
|
1138
|
+
const speed = Math.hypot(vx, vy);
|
|
1139
|
+
|
|
1140
|
+
let best = null;
|
|
1141
|
+
let bestEta = Infinity;
|
|
1142
|
+
for (const node of this._attentionNodes) {
|
|
1143
|
+
// the registry is swept lazily, like `_sizeQueryNodes`: an unmount has
|
|
1144
|
+
// more urgent things to do than reach into every window registry
|
|
1145
|
+
if (node.destroyed || !node.root) {
|
|
1146
|
+
this._attentionNodes.delete(node);
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
const rect = node.abs;
|
|
1150
|
+
// a node that has never been laid out has no rectangle to aim at, and
|
|
1151
|
+
// one that is hidden or untargetable is not somewhere the pointer can
|
|
1152
|
+
// arrive
|
|
1153
|
+
if (!rect?.width || !rect.height) continue;
|
|
1154
|
+
if (node.hidden || node.style.display === 'none') continue;
|
|
1155
|
+
if (node.style.pointerEvents === 'none') continue;
|
|
1156
|
+
const eta =
|
|
1157
|
+
speed < ATTENTION_MIN_SPEED
|
|
1158
|
+
? attentionEta(native.x, native.y, 0, 0, rect)
|
|
1159
|
+
: attentionEta(native.x, native.y, vx, vy, rect);
|
|
1160
|
+
if (eta === null || eta > ATTENTION_HORIZON_MS) continue;
|
|
1161
|
+
if (eta < bestEta) {
|
|
1162
|
+
bestEta = eta;
|
|
1163
|
+
best = node;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
this._setAttention(best, native, bestEta);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Move attention, which only one node in a window can hold.
|
|
1171
|
+
*
|
|
1172
|
+
* **The handler fires on arrival only.** Losing attention is deliberately
|
|
1173
|
+
* not an event: the thing the handler is for is starting work early — a
|
|
1174
|
+
* cache warmed, an image decoded, a query sent — and none of that wants
|
|
1175
|
+
* undoing because the pointer changed its mind. What *is* visual is handled
|
|
1176
|
+
* by `:attention`, which is cleared here like any other state, so the
|
|
1177
|
+
* common case needs no handler at all. If a real use for the loss turns up,
|
|
1178
|
+
* the seam is a second prop rather than a `null` argument every
|
|
1179
|
+
* handler would have to null-check.
|
|
1180
|
+
*
|
|
1181
|
+
* No capture, no bubble: see the note at the top of this file. Attention is
|
|
1182
|
+
* matched against a registry, so the node that matched is the only node
|
|
1183
|
+
* that could meaningfully hear about it.
|
|
1184
|
+
*/
|
|
1185
|
+
_setAttention(node, native, eta = 0) {
|
|
1186
|
+
const previous = this.attentionNode;
|
|
1187
|
+
if (previous === node) return;
|
|
1188
|
+
if (previous && !previous.destroyed) {
|
|
1189
|
+
previous.setStyleState(':attention', false);
|
|
1190
|
+
}
|
|
1191
|
+
this.attentionNode = node;
|
|
1192
|
+
if (!node) return;
|
|
1193
|
+
node.setStyleState(':attention', true);
|
|
1194
|
+
const handler = node.props.unstable_onAttention;
|
|
1195
|
+
if (!handler) return;
|
|
1196
|
+
// `eta` is the point of the event rather than decoration: "the pointer
|
|
1197
|
+
// arrives here in about 40ms" and "in about 200ms" justify very different
|
|
1198
|
+
// amounts of speculative work, and only the renderer knows which it is.
|
|
1199
|
+
const ev = this._makeEvent('attention', native, node, {
|
|
1200
|
+
eta: Math.round(eta),
|
|
1201
|
+
});
|
|
1202
|
+
callHandler(node, 'unstable_onAttention', handler, ev);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
383
1205
|
/** Apply the deepest hovered node's `cursor` prop to the window.
|
|
384
1206
|
* Feature-detected: needs ntk with Window.setCursor (> 3.1.0). */
|
|
385
1207
|
_updateCursor(path) {
|
|
@@ -387,7 +1209,7 @@ export class EventManager {
|
|
|
387
1209
|
if (typeof wnd.setCursor !== 'function') return;
|
|
388
1210
|
let cursor = null;
|
|
389
1211
|
for (let i = path.length - 1; i >= 0; i--) {
|
|
390
|
-
const c = path[i].
|
|
1212
|
+
const c = path[i].style.cursor ?? path[i].defaultCursor;
|
|
391
1213
|
if (c != null) {
|
|
392
1214
|
cursor = c;
|
|
393
1215
|
break;
|
|
@@ -399,58 +1221,374 @@ export class EventManager {
|
|
|
399
1221
|
}
|
|
400
1222
|
}
|
|
401
1223
|
|
|
1224
|
+
/**
|
|
1225
|
+
* The composition state machine for this keyboard focus, or null when the
|
|
1226
|
+
* app turned composition off. On the focus manager, because a composition
|
|
1227
|
+
* belongs to *the* keyboard: a `<popup>` shares the owner window's focus,
|
|
1228
|
+
* and a half-typed accent has to survive a dropdown opening under it.
|
|
1229
|
+
*
|
|
1230
|
+
* Built lazily from the root's table (`createRoot({ compose })`), which is
|
|
1231
|
+
* also why an app object that never went through `createRoot` — a mock, a
|
|
1232
|
+
* unit test — still composes: `composeTableFor` falls back to the
|
|
1233
|
+
* built-ins rather than to nothing.
|
|
1234
|
+
*/
|
|
1235
|
+
_composer() {
|
|
1236
|
+
const manager = this.focusManager;
|
|
1237
|
+
if (manager !== this) return manager._composer();
|
|
1238
|
+
if (this._composerInstance === undefined) {
|
|
1239
|
+
const table = composeTableFor(this.node.app);
|
|
1240
|
+
this._composerInstance = table ? new Composer(table) : null;
|
|
1241
|
+
}
|
|
1242
|
+
return this._composerInstance;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* One composition event, defaultable like any other: the application's
|
|
1247
|
+
* `onCompositionStart` / `onCompositionUpdate` / `onCompositionEnd` first,
|
|
1248
|
+
* then the element's own `defaultComposition` unless one of them called
|
|
1249
|
+
* `preventDefault()`. Same seam, same order as `defaultKeyDown`.
|
|
1250
|
+
*/
|
|
1251
|
+
_composition(phase, target, data, native) {
|
|
1252
|
+
const ev = this.dispatch('Composition' + phase, target, native, { data });
|
|
1253
|
+
if (!ev.defaultPrevented) target.defaultComposition?.(ev);
|
|
1254
|
+
return ev;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/** Run a composer step that has already been probed, as the events an
|
|
1258
|
+
* element and an application see. */
|
|
1259
|
+
_compose(composer, step, target, native) {
|
|
1260
|
+
const was = composer.composing;
|
|
1261
|
+
composer.apply(step);
|
|
1262
|
+
if (!was) this._composition('Start', target, '', native);
|
|
1263
|
+
if (composer.composing) {
|
|
1264
|
+
this._composition('Update', target, step.preedit, native);
|
|
1265
|
+
} else {
|
|
1266
|
+
this._composition('End', target, step.text ?? '', native);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* Abandon an open composition, discarding what it had so far.
|
|
1272
|
+
*
|
|
1273
|
+
* Focus moving, the window losing the keyboard, a press putting the caret
|
|
1274
|
+
* somewhere else: in all three the accent was aimed at a place the user
|
|
1275
|
+
* has left, and committing it there would put a character where nobody
|
|
1276
|
+
* was looking. The element hears an `End` with no data, which is what
|
|
1277
|
+
* clears its preedit.
|
|
1278
|
+
*/
|
|
1279
|
+
_endComposition(native = null) {
|
|
1280
|
+
const manager = this.focusManager;
|
|
1281
|
+
if (manager !== this) return manager._endComposition(native);
|
|
1282
|
+
const composer = this._composerInstance;
|
|
1283
|
+
if (!composer?.composing) return;
|
|
1284
|
+
composer.reset();
|
|
1285
|
+
const focused = this.focused;
|
|
1286
|
+
const target = focused && !focused.destroyed ? focused : this.node;
|
|
1287
|
+
this._composition('End', target, '', native);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
402
1290
|
_onKey(name, native) {
|
|
1291
|
+
if (inspectHandler) {
|
|
1292
|
+
// Escape is the way out of a picker the user changed their mind
|
|
1293
|
+
// about; every other key is swallowed with the pointer.
|
|
1294
|
+
const wnd = this.node.window;
|
|
1295
|
+
const keysym = acceleratorKeysym(
|
|
1296
|
+
this.node.app,
|
|
1297
|
+
native.keycode,
|
|
1298
|
+
native.baseKeysym ?? wnd?.X?.keycode2keysyms?.[native.keycode]?.[0],
|
|
1299
|
+
);
|
|
1300
|
+
if (name === 'KeyDown' && keysym === XK_ESCAPE) {
|
|
1301
|
+
inspectHandler('cancel', null, native);
|
|
1302
|
+
}
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
// The key is this application's; which of its windows the server
|
|
1306
|
+
// addressed it to is not the same question as which window is holding
|
|
1307
|
+
// the keyboard (`_keyManager`). Redirected whole rather than target by
|
|
1308
|
+
// target, so composition, the focus scope Tab cycles inside and the
|
|
1309
|
+
// handlers the event bubbles through are all that window's.
|
|
1310
|
+
const manager = this._keyManager();
|
|
1311
|
+
if (manager !== this) return manager._onKey(name, native);
|
|
403
1312
|
runWithPriority(DiscreteEventPriority, () => {
|
|
404
1313
|
const wnd = this.node.window;
|
|
405
|
-
|
|
406
|
-
|
|
1314
|
+
// ntk decodes the key on the way in (window.js decorates the event
|
|
1315
|
+
// with keysym/baseKeysym/codepoint), so re-deriving it from the
|
|
1316
|
+
// keymap here was redundant. `baseKeysym` — group 1, level 1 — is
|
|
1317
|
+
// what shortcut comparisons want (XK_TAB even under Shift), which is
|
|
1318
|
+
// exactly what the old level-0 read gave. The keymap lookup stays as
|
|
1319
|
+
// the fallback for synthetic events that skip ntk's decoration.
|
|
1320
|
+
//
|
|
1321
|
+
// …and group 1 is Latin only until somebody puts a Cyrillic layout
|
|
1322
|
+
// first, or runs under XQuartz, where a layout switch rewrites the
|
|
1323
|
+
// keymap and leaves no Latin group at all. `acceleratorKeysym` is the
|
|
1324
|
+
// rest of the rule: `ev.keysym` is the Latin keysym for the key, so a
|
|
1325
|
+
// chord keeps matching while `ev.codepoint` types Russian (#85).
|
|
1326
|
+
const keysym = acceleratorKeysym(
|
|
1327
|
+
this.node.app,
|
|
1328
|
+
native.keycode,
|
|
1329
|
+
native.baseKeysym ?? wnd.X?.keycode2keysyms?.[native.keycode]?.[0],
|
|
1330
|
+
);
|
|
407
1331
|
// the focused node may live inside a <popup> of this window: focus is
|
|
408
1332
|
// shared with the popup (see focusManager), key delivery follows it
|
|
409
1333
|
const focused = this.focusManager.focused;
|
|
410
1334
|
const target = focused && !focused.destroyed ? focused : this.node;
|
|
1335
|
+
// Composition reads the keysym the key *typed*, not the base one:
|
|
1336
|
+
// a dead key is routinely a shifted or AltGr level of a key whose
|
|
1337
|
+
// level 1 is an ordinary character, and `baseKeysym` is that
|
|
1338
|
+
// character. Shortcuts want the base — the reason it is on the event
|
|
1339
|
+
// at all — and composition wants what was actually produced.
|
|
1340
|
+
// …and it does not run at all for an element that forwards raw key
|
|
1341
|
+
// events to something with an input method of its own: composing here
|
|
1342
|
+
// would eat the dead key on its way to an embedded client, which then
|
|
1343
|
+
// receives neither the key nor the character (`<foreign>`).
|
|
1344
|
+
const composer =
|
|
1345
|
+
name === 'KeyDown' && target.composes !== false
|
|
1346
|
+
? this._composer()
|
|
1347
|
+
: null;
|
|
1348
|
+
const step = composer?.probe(native.keysym ?? keysym) ?? null;
|
|
1349
|
+
const composing = Boolean(step?.consumed);
|
|
411
1350
|
const ev = this.dispatch(name, target, native, {
|
|
412
1351
|
keycode: native.keycode,
|
|
413
1352
|
keysym,
|
|
414
|
-
|
|
1353
|
+
// Which layout typed this, 0-3 — the XKB group, from bits 13-14 of
|
|
1354
|
+
// the event's state field. The one thing that says a layout switch
|
|
1355
|
+
// happened at all: switching sends no MappingNotify, because on
|
|
1356
|
+
// Linux the keymap did not change, only which part of it is live.
|
|
1357
|
+
group: native.group ?? 0,
|
|
1358
|
+
// A key the composition is going to take types nothing on its own:
|
|
1359
|
+
// its text arrives on the composition event instead. Reporting the
|
|
1360
|
+
// code point as well is what would make `Compose o c` insert `oc©`
|
|
1361
|
+
// in any application that types from `onKeyDown` — the renderer's
|
|
1362
|
+
// own elements included.
|
|
1363
|
+
codepoint: composing ? undefined : native.codepoint,
|
|
415
1364
|
key:
|
|
416
|
-
native.codepoint && native.codepoint >= 0x20
|
|
1365
|
+
!composing && native.codepoint && native.codepoint >= 0x20
|
|
417
1366
|
? String.fromCodePoint(native.codepoint)
|
|
418
1367
|
: undefined,
|
|
419
|
-
|
|
420
|
-
ctrlKey: Boolean(native.buttons & 4),
|
|
1368
|
+
composing,
|
|
421
1369
|
});
|
|
422
|
-
if (
|
|
423
|
-
|
|
1370
|
+
if (ev.defaultPrevented) return;
|
|
1371
|
+
// A release has no traversal after it, but it does have a default
|
|
1372
|
+
// action: an element that answers the whole keystroke rather than the
|
|
1373
|
+
// press — one forwarding into an embedded client (`<foreign>`) — needs
|
|
1374
|
+
// the other half of the pair, or the client sees a key that never
|
|
1375
|
+
// comes up.
|
|
1376
|
+
if (name !== 'KeyDown') {
|
|
1377
|
+
target.defaultKeyUp?.(ev);
|
|
424
1378
|
return;
|
|
425
1379
|
}
|
|
426
|
-
|
|
427
|
-
|
|
1380
|
+
// App chords, then composition, then the element, then focus
|
|
1381
|
+
// traversal. Composition sits above the element so that a dead key
|
|
1382
|
+
// cannot also trigger an editing action, and below the application so
|
|
1383
|
+
// that an `onKeyDown` chord still wins — `preventDefault()` above
|
|
1384
|
+
// returned already, with the composer's state untouched, which is why
|
|
1385
|
+
// `probe` and `apply` are separate.
|
|
1386
|
+
if (step && (step.consumed || step.text != null)) {
|
|
1387
|
+
this._compose(composer, step, target, native);
|
|
1388
|
+
// A key that ended a sequence without belonging to it — an arrow
|
|
1389
|
+
// after a pending accent — has committed the accent and now takes
|
|
1390
|
+
// its ordinary turn.
|
|
1391
|
+
if (step.consumed) return;
|
|
1392
|
+
}
|
|
1393
|
+
// The element's own behaviour first, focus traversal after it — Tab is
|
|
1394
|
+
// an ordinary defaultable key rather than one the focus manager eats on
|
|
1395
|
+
// the way past. Cycling first meant an editor could only keep Tab as an
|
|
1396
|
+
// indent key through a *user-level* handler, which is a wiring an
|
|
1397
|
+
// element cannot ship with itself (issue #251).
|
|
1398
|
+
//
|
|
1399
|
+
// A default action that consumed the key says so by calling
|
|
1400
|
+
// `preventDefault()` — the same word, one layer down: what it prevents
|
|
1401
|
+
// now is the default action left after it, which for Tab is the focus
|
|
1402
|
+
// cycle. Nothing in core consumes Tab, so `<textinput>` and friends
|
|
1403
|
+
// still hand it straight to traversal.
|
|
1404
|
+
target.defaultKeyDown?.(ev);
|
|
1405
|
+
// Then the accelerators — a menu item's `shortcut`, a `useAccelerator`
|
|
1406
|
+
// — on that same rule, which is the whole of what keeps Ctrl+C in a
|
|
1407
|
+
// focused field: the element answered it and said so, and an
|
|
1408
|
+
// application-wide binding does not get to take it back (#351).
|
|
1409
|
+
if (!ev.defaultPrevented) this._runAccelerators(ev);
|
|
1410
|
+
if (keysym === XK_TAB && !ev.defaultPrevented) {
|
|
1411
|
+
this._cycleFocus(Boolean(native.buttons & 1));
|
|
428
1412
|
}
|
|
429
1413
|
});
|
|
430
1414
|
}
|
|
431
1415
|
|
|
432
|
-
|
|
1416
|
+
/**
|
|
1417
|
+
* Bind a chord for as long as the caller is mounted. Returns the release.
|
|
1418
|
+
*
|
|
1419
|
+
* `entry.anchor()` is the node the binding belongs to, read at dispatch
|
|
1420
|
+
* time rather than captured: a `MenuBar` that the desktop's panel takes
|
|
1421
|
+
* over stops drawing a bar and still has to deliver the key, so what the
|
|
1422
|
+
* binding hangs off changes under it.
|
|
1423
|
+
*
|
|
1424
|
+
* `entry.handle(ev)` answers whether it took the key.
|
|
1425
|
+
*
|
|
1426
|
+
* Registrations live on the **top-level** manager, which is the one thing
|
|
1427
|
+
* a `<popup>`, a nested `<window>` and the window itself all share — the
|
|
1428
|
+
* same grouping `_keyManager` resolves keys through. So a chord reaches
|
|
1429
|
+
* the whole of one window's tree and none of another's.
|
|
1430
|
+
*/
|
|
1431
|
+
registerAccelerator(entry) {
|
|
1432
|
+
const owner = this.topLevelManager;
|
|
1433
|
+
(owner._accelerators ??= new Set()).add(entry);
|
|
1434
|
+
return () => {
|
|
1435
|
+
owner._accelerators?.delete(entry);
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Offer a key to the bindings, most recently mounted first, and stop at
|
|
1441
|
+
* the first that takes it.
|
|
1442
|
+
*
|
|
1443
|
+
* Two things gate a binding, and both are questions the menu already knows
|
|
1444
|
+
* the answer to and an `onKeyDown` does not:
|
|
1445
|
+
*
|
|
1446
|
+
* - **it is on the screen.** A binding whose node has unmounted or gone
|
|
1447
|
+
* behind a `display: 'none'`, a collapsed `<Suspense>` or an unmapped
|
|
1448
|
+
* `<popup>` is a menu the user cannot open, so it is not a chord they
|
|
1449
|
+
* can press either. Same question `focus` asks of a hidden subtree.
|
|
1450
|
+
* - **it is inside the innermost focus scope.** A modal `<popup>`
|
|
1451
|
+
* (`trapFocus`) takes the keyboard from everything behind it, and that
|
|
1452
|
+
* has to include the application's shortcuts — Ctrl+S while a
|
|
1453
|
+
* confirmation is up saves nothing. A menu declared *inside* the modal
|
|
1454
|
+
* is inside the scope and still works, which is the same containment
|
|
1455
|
+
* rule Tab traversal follows. With no modal open the scope root is the
|
|
1456
|
+
* window itself and every binding in it qualifies.
|
|
1457
|
+
*
|
|
1458
|
+
* A menu that is *open* suppresses them by another route entirely: it
|
|
1459
|
+
* `preventDefault()`s the keys it is being driven with, one layer above
|
|
1460
|
+
* this (`components/Menu.js`).
|
|
1461
|
+
*/
|
|
1462
|
+
_runAccelerators(ev) {
|
|
1463
|
+
const owner = this.topLevelManager;
|
|
1464
|
+
const entries = owner._accelerators;
|
|
1465
|
+
if (!entries?.size) return;
|
|
1466
|
+
const scopeRoot = owner._scopeRoot();
|
|
1467
|
+
const trapped = scopeRoot !== owner.node;
|
|
1468
|
+
for (const entry of [...entries].reverse()) {
|
|
1469
|
+
const anchor = entry.anchor();
|
|
1470
|
+
if (!anchor || anchor.destroyed || !effectivelyVisible(anchor)) continue;
|
|
1471
|
+
if (trapped && !owner._within(anchor, scopeRoot)) continue;
|
|
1472
|
+
// A handler that threw still *took* the key: the chord matched, and
|
|
1473
|
+
// offering it to the next binding would run a second command because
|
|
1474
|
+
// the first one failed. Reported and carried on, like every other
|
|
1475
|
+
// throw out of an X event (`errors.js`).
|
|
1476
|
+
let took = true;
|
|
1477
|
+
try {
|
|
1478
|
+
took = Boolean(entry.handle(ev));
|
|
1479
|
+
} catch (error) {
|
|
1480
|
+
reportHandlerError(anchor, 'an accelerator', error);
|
|
1481
|
+
}
|
|
1482
|
+
if (took) {
|
|
1483
|
+
// consumed, said the way every default action says it
|
|
1484
|
+
ev.preventDefault();
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Move focus, and record *how* it moved.
|
|
1492
|
+
*
|
|
1493
|
+
* `reason` is what separates `:focus` from `:focus-visible`. A press is
|
|
1494
|
+
* `'pointer'` and lights no ring: the user knows where they clicked, and a
|
|
1495
|
+
* ring on every click is the noise CSS grew `:focus-visible` to remove.
|
|
1496
|
+
* Everything else — Tab, an arrow inside a widget, `autoFocus`, a modal
|
|
1497
|
+
* handing focus back as it closes, `node.focus()` from an application —
|
|
1498
|
+
* lights one, because none of those tell the user where focus went.
|
|
1499
|
+
*
|
|
1500
|
+
* `backwards` is only meaningful for `reason: 'key'`, and only one element
|
|
1501
|
+
* has ever needed it: XEmbed distinguishes a Tab arriving forwards from a
|
|
1502
|
+
* back-Tab, because that is what tells an embedded client whether to focus
|
|
1503
|
+
* its first widget or its last (`<foreign>`, src/foreignnodes.js). It
|
|
1504
|
+
* reaches the node through `defaultFocus({ reason, backwards })`.
|
|
1505
|
+
*
|
|
1506
|
+
* `restoring` says this is a subtree coming back out of hiding rather than
|
|
1507
|
+
* focus going somewhere new (`subtreeRevealed`), and it turns off the two
|
|
1508
|
+
* things that move the world to meet the node.
|
|
1509
|
+
*/
|
|
1510
|
+
focus(node, reason = 'script', options = {}) {
|
|
433
1511
|
const manager = this.focusManager;
|
|
434
|
-
if (manager !== this) return manager.focus(node);
|
|
1512
|
+
if (manager !== this) return manager.focus(node, reason, options);
|
|
1513
|
+
const { backwards = false, restoring = false } = options;
|
|
435
1514
|
if (node === this.focused) return;
|
|
1515
|
+
// before `focused` moves, so the element that was collecting the
|
|
1516
|
+
// sequence is the one told to drop it
|
|
1517
|
+
this._endComposition();
|
|
436
1518
|
const old = this.focused;
|
|
437
1519
|
this._previousFocus = old;
|
|
438
1520
|
this.focused = node;
|
|
1521
|
+
// nothing has been told anything about the keyboard yet, whatever the
|
|
1522
|
+
// node that just gave up focus was told (`_syncDefaultFocus`)
|
|
1523
|
+
this._defaultFocusOn = false;
|
|
1524
|
+
// …and this window is now the one the top-level's keys belong to,
|
|
1525
|
+
// whichever of its windows they are addressed to (`_keyManager`)
|
|
1526
|
+
if (node) this.topLevelManager._focusHolder = this;
|
|
439
1527
|
if (old && !old.destroyed) {
|
|
440
|
-
|
|
1528
|
+
// Claimed *first*, while the ring is still on: a node's damage bound
|
|
1529
|
+
// only reaches outside its box while it is actually drawing an
|
|
1530
|
+
// outline, so a claim taken after the state flipped back would leave
|
|
1531
|
+
// the ring's pixels behind.
|
|
1532
|
+
old.root?.invalidate(false, old, 'focus');
|
|
1533
|
+
old.setStyleState(':focus', false);
|
|
1534
|
+
old.setStyleState(':focus-visible', false);
|
|
1535
|
+
old.defaultBlur?.();
|
|
441
1536
|
old.props.onBlur?.(this._makeEvent('blur', null, old));
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
old.root?.invalidate(false);
|
|
1537
|
+
// it may be in another window than the new focus — owner window ↔ its
|
|
1538
|
+
// popup — and the caret it was drawing has to go too
|
|
1539
|
+
old.root?.invalidate(false, old, 'focus');
|
|
445
1540
|
}
|
|
446
1541
|
if (node) {
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
1542
|
+
// Keys only reach a node whose window has the X focus — but a restore
|
|
1543
|
+
// must not *take* it: a window revealing something in the background
|
|
1544
|
+
// would pull the keyboard off another application, and SetInputFocus
|
|
1545
|
+
// on a window the reveal has not mapped yet is a BadMatch. Asked of
|
|
1546
|
+
// the focus group rather than of this window, or focusing something
|
|
1547
|
+
// inside an open `<Dialog>` would drag the keyboard off the dialog
|
|
1548
|
+
// and back onto the window that owns it.
|
|
1549
|
+
if (!this.keyboardFocused && !restoring) this.node.window?.focus?.();
|
|
1550
|
+
node.setStyleState(':focus', true);
|
|
1551
|
+
node.setStyleState(':focus-visible', reason !== 'pointer');
|
|
1552
|
+
// …and nothing scrolls to meet it either: the node is coming back to
|
|
1553
|
+
// the arrangement it left, and this reveal's layout has not run, so a
|
|
1554
|
+
// scroll here would be computed from a rect that does not exist yet.
|
|
1555
|
+
if (!restoring) this._scrollIntoView(node);
|
|
1556
|
+
if (this.keyboardFocused) {
|
|
1557
|
+
this._defaultFocusOn = true;
|
|
1558
|
+
node.defaultFocus?.({ reason, backwards });
|
|
1559
|
+
}
|
|
451
1560
|
node.props.onFocus?.(this._makeEvent('focus', null, node));
|
|
452
|
-
node.root?.invalidate(false);
|
|
1561
|
+
node.root?.invalidate(false, node, 'focus');
|
|
1562
|
+
}
|
|
1563
|
+
this._updateFocusWithin(node);
|
|
1564
|
+
a11yHooks.focus?.(old, node);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/**
|
|
1568
|
+
* Flip `:focus-within` over the focused node's ancestor chain, diffed the
|
|
1569
|
+
* way hover and the press chain are.
|
|
1570
|
+
*
|
|
1571
|
+
* It is the answer to "this row should light up while the field inside it
|
|
1572
|
+
* has focus", which is otherwise the one thing a state block cannot say —
|
|
1573
|
+
* the field is a descendant, and the row has no state of its own to react
|
|
1574
|
+
* to. Nothing relational is added by it: the chain is a walk up `parent`,
|
|
1575
|
+
* the same one `Node.focusWithin` already reports, and a `<popup>` counts
|
|
1576
|
+
* as inside the node it hangs off in the JSX tree, so a `Select` with its
|
|
1577
|
+
* menu open still reads as focused.
|
|
1578
|
+
*/
|
|
1579
|
+
_updateFocusWithin(node) {
|
|
1580
|
+
const path = [];
|
|
1581
|
+
for (let n = node; n; n = n.parent) path.push(n);
|
|
1582
|
+
path.reverse();
|
|
1583
|
+
const old = this.focusWithinPath;
|
|
1584
|
+
const common = sharedPrefix(old, path);
|
|
1585
|
+
for (let i = old.length - 1; i >= common; i--) {
|
|
1586
|
+
if (!old[i].destroyed) old[i].setStyleState(':focus-within', false);
|
|
453
1587
|
}
|
|
1588
|
+
for (let i = common; i < path.length; i++) {
|
|
1589
|
+
path[i].setStyleState(':focus-within', true);
|
|
1590
|
+
}
|
|
1591
|
+
this.focusWithinPath = path;
|
|
454
1592
|
}
|
|
455
1593
|
|
|
456
1594
|
/**
|
|
@@ -483,9 +1621,25 @@ export class EventManager {
|
|
|
483
1621
|
const focused = this.focused;
|
|
484
1622
|
// focus only comes back if it was inside the scope that just closed
|
|
485
1623
|
if (focused && !this._within(focused, node)) return;
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
1624
|
+
this.focus(this._canRestoreTo(scope.restore) ? scope.restore : null);
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* Somewhere focus can be handed *back* to: still in the tree, still
|
|
1629
|
+
* focusable, and still on screen.
|
|
1630
|
+
*
|
|
1631
|
+
* Three callers ask it — a focus scope closing, an edit menu closing
|
|
1632
|
+
* (`closeEditMenu`, nodes.js) and a subtree coming out of hiding — and the
|
|
1633
|
+
* third is why the question includes visibility. Whatever a modal was
|
|
1634
|
+
* opened from may have suspended while it was up, and handing the keyboard
|
|
1635
|
+
* back to it would put keys on an invisible control by the other route.
|
|
1636
|
+
*
|
|
1637
|
+
* "Still in the tree" is part of the same answer rather than a check of its
|
|
1638
|
+
* own: `effectivelyVisible` starts at `destroyed`, since a node that has
|
|
1639
|
+
* left is not on screen either.
|
|
1640
|
+
*/
|
|
1641
|
+
_canRestoreTo(node) {
|
|
1642
|
+
return Boolean(node && this._isFocusable(node) && effectivelyVisible(node));
|
|
489
1643
|
}
|
|
490
1644
|
|
|
491
1645
|
/** The innermost live focus scope, or the window node when there is none.
|
|
@@ -508,10 +1662,16 @@ export class EventManager {
|
|
|
508
1662
|
return false;
|
|
509
1663
|
}
|
|
510
1664
|
|
|
511
|
-
/**
|
|
1665
|
+
/**
|
|
1666
|
+
* Tab to something inside a scrolling box and it should be on screen.
|
|
1667
|
+
*
|
|
1668
|
+
* Asks `isScroller()` rather than whether the method exists: every `<box>`
|
|
1669
|
+
* carries `scrollIntoView` now, and one that is not a scroll container
|
|
1670
|
+
* would end the walk without revealing anything.
|
|
1671
|
+
*/
|
|
512
1672
|
_scrollIntoView(node) {
|
|
513
1673
|
for (let n = node.parent; n; n = n.parent) {
|
|
514
|
-
if (
|
|
1674
|
+
if (n.isScroller?.()) {
|
|
515
1675
|
n.scrollIntoView(node);
|
|
516
1676
|
return;
|
|
517
1677
|
}
|
|
@@ -521,11 +1681,15 @@ export class EventManager {
|
|
|
521
1681
|
/** Focusable nodes in tree order, from `root` down. Windows are their own
|
|
522
1682
|
* focus roots, so a nested `<window>` or `<popup>` is not walked into —
|
|
523
1683
|
* except when it _is_ the root, which is how a modal popup's own
|
|
524
|
-
* focusables are reached.
|
|
1684
|
+
* focusables are reached.
|
|
1685
|
+
*
|
|
1686
|
+
* Invisible either way is invisible: `display: 'none'` takes a subtree out
|
|
1687
|
+
* of the layout, the paint and the hit test, so Tab has no business
|
|
1688
|
+
* landing in it either. */
|
|
525
1689
|
_focusables(root = this._scopeRoot()) {
|
|
526
1690
|
const out = [];
|
|
527
1691
|
const walk = (node) => {
|
|
528
|
-
if (node.hidden) return;
|
|
1692
|
+
if (node.hidden || node.style.display === 'none') return;
|
|
529
1693
|
if (this._isFocusable(node)) out.push(node);
|
|
530
1694
|
for (const child of node.children) {
|
|
531
1695
|
if (!child.isWindow) walk(child);
|
|
@@ -560,25 +1724,105 @@ export class EventManager {
|
|
|
560
1724
|
const index = list.indexOf(this.focused);
|
|
561
1725
|
if (index === -1) {
|
|
562
1726
|
// nothing focused, or focus sits outside the current scope: Tab enters
|
|
563
|
-
this.focus(backwards ? list[list.length - 1] : list[0]
|
|
1727
|
+
this.focus(backwards ? list[list.length - 1] : list[0], 'key', {
|
|
1728
|
+
backwards,
|
|
1729
|
+
});
|
|
564
1730
|
return;
|
|
565
1731
|
}
|
|
566
1732
|
this.focus(
|
|
567
1733
|
backwards
|
|
568
1734
|
? list[(index || list.length) - 1]
|
|
569
1735
|
: list[(index + 1) % list.length],
|
|
1736
|
+
'key',
|
|
1737
|
+
{ backwards },
|
|
570
1738
|
);
|
|
571
1739
|
}
|
|
572
1740
|
|
|
1741
|
+
/**
|
|
1742
|
+
* A subtree just stopped being visible: `<Suspense>` showing its fallback,
|
|
1743
|
+
* `<Activity mode="hidden">`, or a style that turned `display: 'none'`.
|
|
1744
|
+
*
|
|
1745
|
+
* **Focus follows visibility.** Every other route already read it that way
|
|
1746
|
+
* — `hitTest` and `paintOrder` skip the node, `_focusables` will not Tab
|
|
1747
|
+
* into it — and focus was the one left open, so keys kept landing on a
|
|
1748
|
+
* control the user could no longer see and the application's state kept
|
|
1749
|
+
* advancing from them (issue #202). In a browser the browser plays this
|
|
1750
|
+
* part; here nobody did.
|
|
1751
|
+
*
|
|
1752
|
+
* The question is **containment**, not identity. React calls `hideInstance`
|
|
1753
|
+
* on the topmost host instance of a hidden branch, so in any tree deeper
|
|
1754
|
+
* than one node the focused control still has `hidden === false` itself and
|
|
1755
|
+
* is invisible only because a yoga ancestor is `DISPLAY_NONE` — the same
|
|
1756
|
+
* question `popScope` asks on the way out of a modal. `effectivelyVisible`
|
|
1757
|
+
* is the second half of it, and it is what tells a `<box>` inside the
|
|
1758
|
+
* hidden node from a `<popup>` hanging off it: a popup is its own X window,
|
|
1759
|
+
* nothing unmapped it, and it is still on screen holding the keyboard.
|
|
1760
|
+
*/
|
|
1761
|
+
subtreeHidden(node) {
|
|
1762
|
+
const manager = this.focusManager;
|
|
1763
|
+
if (manager !== this) return manager.subtreeHidden(node);
|
|
1764
|
+
const focused = this.focused;
|
|
1765
|
+
if (!focused || !node.contains(focused)) return;
|
|
1766
|
+
if (effectivelyVisible(focused)) return;
|
|
1767
|
+
// Ring included: a restore puts back the state hiding took away rather
|
|
1768
|
+
// than making a new focus, and whether the user could see where focus
|
|
1769
|
+
// was is part of that state.
|
|
1770
|
+
this._hiddenFocus.set(node, {
|
|
1771
|
+
node: focused,
|
|
1772
|
+
visible: focused.states[':focus-visible'] === true,
|
|
1773
|
+
});
|
|
1774
|
+
this.focus(null);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
/**
|
|
1778
|
+
* …and the way back, which is the half that is a decision rather than a
|
|
1779
|
+
* bug fix.
|
|
1780
|
+
*
|
|
1781
|
+
* It restores. `<Activity>` exists to keep what a hidden subtree had, and a
|
|
1782
|
+
* boundary re-suspending is not something the user did — so a field they
|
|
1783
|
+
* were typing in comes back focused and they carry on, instead of being
|
|
1784
|
+
* silently dropped out of it by a fallback that flashed.
|
|
1785
|
+
*
|
|
1786
|
+
* Two rules keep that from being focus stealing, and they are what make the
|
|
1787
|
+
* restore safe enough to be the default. It only happens when **nothing
|
|
1788
|
+
* else has the keyboard**: anything focused while the subtree was away
|
|
1789
|
+
* keeps focus, so a reveal somewhere the user is not looking can never take
|
|
1790
|
+
* over what they are doing. And it is a *restore* rather than a navigation
|
|
1791
|
+
* — no scroll, no pull on the X input focus (see `focus`).
|
|
1792
|
+
*
|
|
1793
|
+
* `createRoot({ restoreFocusOnReveal: false })` is the way out, for an app
|
|
1794
|
+
* that wants the browser's answer: focus that fell to the body stays there,
|
|
1795
|
+
* and coming back is the user's own Tab.
|
|
1796
|
+
*/
|
|
1797
|
+
subtreeRevealed(node) {
|
|
1798
|
+
const manager = this.focusManager;
|
|
1799
|
+
if (manager !== this) return manager.subtreeRevealed(node);
|
|
1800
|
+
const was = this._hiddenFocus.get(node);
|
|
1801
|
+
if (!was) return;
|
|
1802
|
+
this._hiddenFocus.delete(node);
|
|
1803
|
+
if (this.focused) return;
|
|
1804
|
+
if (!restoresFocusOnReveal(this.node.app)) return;
|
|
1805
|
+
// it may have been unmounted, or hidden again by something inside the
|
|
1806
|
+
// subtree, while it was away
|
|
1807
|
+
if (!this._canRestoreTo(was.node)) return;
|
|
1808
|
+
this.focus(was.node, was.visible ? 'script' : 'pointer', {
|
|
1809
|
+
restoring: true,
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
|
|
573
1813
|
/** Called when a node leaves the tree so stale references don't linger. */
|
|
574
1814
|
forget(node) {
|
|
575
1815
|
if (this.downNode === node) this.downNode = null;
|
|
576
1816
|
if (this.capturedNode === node) this.capturedNode = null;
|
|
1817
|
+
this.node._dragSession?.forget(node);
|
|
577
1818
|
this.hoverPath = this.hoverPath.filter((n) => n !== node);
|
|
578
1819
|
const manager = this.focusManager;
|
|
579
1820
|
// a scope closing restores focus, so pop before the focus reference goes
|
|
580
1821
|
manager.popScope(node);
|
|
581
1822
|
if (manager.focused === node) manager.focused = null;
|
|
582
1823
|
if (manager._previousFocus === node) manager._previousFocus = null;
|
|
1824
|
+
// the ancestors above a departing node keep `:focus-within` until focus
|
|
1825
|
+
// actually moves, which is right — what must not survive is the node
|
|
1826
|
+
manager.focusWithinPath = manager.focusWithinPath.filter((n) => n !== node);
|
|
583
1827
|
}
|
|
584
1828
|
}
|