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.
Files changed (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. 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 mapping (X buttons 4-7) and focus/Tab traversal.
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 WHEEL_BUTTONS = { 4: [0, -48], 5: [0, 48], 6: [-48, 0], 7: [48, 0] };
13
- // X11 KeyButMask bit for Mod1 (Alt on virtually every layout), same bitmask
14
- // `shiftKey`/`ctrlKey` above already read `buttons` from.
15
- const MOD1_MASK = 8;
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
- /** DOM-style click counting: repeated presses within 400ms / 4px bump
68
- * `detail` (2 = double click, 3 = triple …). */
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 < 400 &&
74
- Math.abs(native.x - last.x) <= 4 &&
75
- Math.abs(native.y - last.y) <= 4
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
- wnd.on('mousedown', (ev) => this._onMouseDown(ev));
86
- wnd.on('mouseup', (ev) => this._onMouseUp(ev));
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
- wnd.on('keydown', (ev) => this._onKey('KeyDown', ev));
90
- wnd.on('keyup', (ev) => this._onKey('KeyUp', ev));
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
- wnd.on('focus', (ev) => this._onWindowFocus(true, ev));
94
- wnd.on('blur', (ev) => this._onWindowFocus(false, ev));
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
- if (this.windowFocused === focused) return;
586
+ const changed = this.windowFocused !== focused;
105
587
  this.windowFocused = focused;
106
- const node = this.focused;
107
- if (node && !node.destroyed) {
108
- if (focused) node._defaultFocus?.();
109
- else node._defaultBlur?.();
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.unshift(n);
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
- const ev = {
143
- type,
144
- x: native?.x ?? 0,
145
- y: native?.y ?? 0,
146
- target: this._public(target),
147
- currentTarget: null,
148
- nativeEvent: native,
149
- // X11 modifier mask: bit 0 Shift, bit 2 Control. Carried on every
150
- // event, not just keys — shift+click needs it too.
151
- shiftKey: Boolean(native?.buttons & 1),
152
- ctrlKey: Boolean(native?.buttons & 4),
153
- defaultPrevented: false,
154
- propagationStopped: false,
155
- preventDefault() {
156
- ev.defaultPrevented = true;
157
- },
158
- stopPropagation() {
159
- ev.propagationStopped = true;
160
- },
161
- // Pointer capture, DOM-like: while captured, mousemove/mouseup go to
162
- // the capturing node instead of whatever is under the pointer, so a
163
- // drag keeps working past the widget's own bounds. Released
164
- // automatically on mouseup and when the node unmounts.
165
- capturePointer: () => {
166
- this.capturedNode = target;
167
- },
168
- releasePointer: () => {
169
- if (this.capturedNode === target) this.capturedNode = null;
170
- },
171
- ...extra,
172
- };
173
- if (target.abs) {
174
- ev.localX = ev.x - target.abs.x;
175
- ev.localY = ev.y - target.abs.y;
176
- }
177
- return ev;
639
+ return new SyntheticEvent(this, type, native, target, extra);
178
640
  }
179
641
 
180
- /** Capture → target → bubble along the ancestor path. Returns the event. */
181
- dispatch(name, target, native, extra) {
182
- const path = this._path(target);
183
- const ev = this._makeEvent(
184
- name[0].toLowerCase() + name.slice(1),
185
- native,
186
- target,
187
- extra,
188
- );
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[`on${name}Capture`];
657
+ const handler = n.props[captureKey];
191
658
  if (handler) {
192
659
  ev.currentTarget = this._public(n);
193
- handler(ev);
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[`on${name}`];
665
+ const handler = path[i].props[bubbleKey];
199
666
  if (handler) {
200
667
  ev.currentTarget = this._public(path[i]);
201
- handler(ev);
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
- _onMouseDown(native) {
222
- if (clickToComponentHandler && Boolean(native.buttons & MOD1_MASK)) {
223
- clickToComponentHandler(this._hit(native), native);
224
- return;
225
- }
226
- if (this._pressOutside(native)) {
227
- runWithPriority(DiscreteEventPriority, () => {
228
- this.node.props.onDismiss?.(
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('MouseDown', target, native, {
257
- button: native.keycode,
258
- detail: this._clickDetail(native),
259
- });
260
- if (!ev.defaultPrevented) {
261
- target._defaultMouseDown?.(ev);
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[native.keycode]) return; // wheel release
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
- if (this.downNode && !this.downNode.destroyed) {
277
- this.downNode._defaultMouseUp?.(ev);
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 : this._path(target), native);
305
- const ev = this.dispatch('MouseMove', target, native);
306
- // drags deliver to the pressed node even when the pointer leaves it
307
- if (this.downNode && !this.downNode.destroyed) {
308
- this.downNode._defaultMouseDrag?.(ev);
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
- if (node.props.disabled) return false;
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
- let common = 0;
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 (!n.destroyed) {
372
- n.props.onMouseLeave?.(this._makeEvent('mouseLeave', native, n));
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].props.cursor ?? path[i].defaultCursor;
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
- const syms = wnd.X?.keycode2keysyms?.[native.keycode];
406
- const keysym = syms?.[0];
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
- codepoint: native.codepoint,
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
- shiftKey: Boolean(native.buttons & 1),
420
- ctrlKey: Boolean(native.buttons & 4),
1368
+ composing,
421
1369
  });
422
- if (name === 'KeyDown' && keysym === XK_TAB && !ev.defaultPrevented) {
423
- this._cycleFocus(Boolean(native.buttons & 1));
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
- if (name === 'KeyDown' && !ev.defaultPrevented) {
427
- target._defaultKeyDown?.(ev);
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
- focus(node) {
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
- old._defaultBlur?.();
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
- // the ring/caret it was drawing has to go, and it may be in another
443
- // window than the new focus (owner window its popup)
444
- old.root?.invalidate(false);
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
- // keys only reach a node whose window has the X focus
448
- if (!this.windowFocused) this.node.window?.focus?.();
449
- this._scrollIntoView(node);
450
- if (this.windowFocused) node._defaultFocus?.();
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
- const restore = scope.restore;
487
- const alive = restore && !restore.destroyed && this._isFocusable(restore);
488
- this.focus(alive ? restore : null);
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
- /** Tab to something inside a scrollview and it should be on screen. */
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 (typeof n.scrollIntoView === 'function') {
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
  }