react-x11 2.16.0 → 2.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.16.0",
3
+ "version": "2.16.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -11,6 +11,35 @@ import { useEffect, useMemo, useRef } from 'react';
11
11
  import { matchesShortcut } from './accelerators.js';
12
12
  import { useTopLevelWindow } from './windowid.js';
13
13
 
14
+ /**
15
+ * A binding whose anchor resolved to nothing binds nothing — there is no
16
+ * event manager to register it with, and the effect runs once, so it will
17
+ * not come back. Silent, that is a shortcut that simply never fires, with
18
+ * the tree, the chord and the handler all looking correct (issue #616).
19
+ *
20
+ * Once per process, in development: the mistake is structural, and an app
21
+ * that made it once made it for every chord it declares.
22
+ */
23
+ let warnedAboutAnchor = false;
24
+ export function resetAcceleratorWarningForTests() {
25
+ warnedAboutAnchor = false;
26
+ }
27
+
28
+ function warnUnanchored() {
29
+ if (process.env.NODE_ENV === 'production' || warnedAboutAnchor) return;
30
+ warnedAboutAnchor = true;
31
+ console.warn(
32
+ 'react-x11: a shortcut was bound in a component with no window to hang ' +
33
+ 'it off, so it is bound to nothing and will never fire. By default a ' +
34
+ "binding belongs to the tree's top-level <window> or, in an app with " +
35
+ 'none, the root-level <popup> holding the keyboard. Anchor it ' +
36
+ 'explicitly with `scope`:\n' +
37
+ ' const here = useRef(null);\n' +
38
+ " useAccelerator([['space']], onToggle, { scope: here });\n" +
39
+ ' return <box ref={here}>…</box>;',
40
+ );
41
+ }
42
+
14
43
  /**
15
44
  * Bind a chord for as long as this component is mounted, anchored at
16
45
  * `anchorRef` — the node the binding belongs to, which is what decides
@@ -30,7 +59,10 @@ export function useAcceleratorEntry(anchorRef, handle, enabled = true) {
30
59
  useEffect(() => {
31
60
  if (!enabled) return undefined;
32
61
  const manager = anchorRef.current?.root?.events;
33
- if (!manager) return undefined;
62
+ if (!manager) {
63
+ warnUnanchored();
64
+ return undefined;
65
+ }
34
66
  return manager.registerAccelerator({
35
67
  anchor: () => anchorRef.current ?? null,
36
68
  handle: (ev) => live.current?.(ev) ?? false,
@@ -52,11 +84,13 @@ export function useAcceleratorEntry(anchorRef, handle, enabled = true) {
52
84
  * The handler is called with the key event, and the key is consumed.
53
85
  *
54
86
  * By default the binding belongs to the window the component is in, which is
55
- * what an application-wide shortcut wants. Two options for when it is not:
56
- * `enabled: false` unbinds it without unmounting anything, and `scope` takes
57
- * a ref to a node the binding hangs off instead the way to give a modal
58
- * `<Dialog>` a shortcut of its own, since a binding on the window behind it
59
- * is one the modal has taken the keyboard from.
87
+ * what an application-wide shortcut wants or, in an app with no `<window>`
88
+ * at all, to the root-level `<popup>` that took the keyboard, which is the
89
+ * whole of a tray popover's UI (`useTopLevelWindow`). Two options for when
90
+ * that is not it: `enabled: false` unbinds it without unmounting anything,
91
+ * and `scope` takes a ref to a node the binding hangs off instead — the way
92
+ * to give a modal `<Dialog>` a shortcut of its own, since a binding on the
93
+ * window behind it is one the modal has taken the keyboard from.
60
94
  */
61
95
  export function useAccelerator(shortcut, handler, options = {}) {
62
96
  const { enabled = true, scope } = options;
package/src/anchor.js CHANGED
@@ -148,7 +148,20 @@ export function deviceAnchorArea(node) {
148
148
  if (!app) return null;
149
149
  const at = screenRect(node);
150
150
  const s = node.scale ?? 1;
151
- return availableArea(app, at ? { x: at.x * s, y: at.y * s } : null);
151
+ // The whole rect, not its corner: a window straddling two monitors has
152
+ // nodes on each, and the monitor a node is on is the one it covers most of
153
+ // (`monitorAt`, src/screens.js).
154
+ return availableArea(
155
+ app,
156
+ at
157
+ ? {
158
+ x: at.x * s,
159
+ y: at.y * s,
160
+ width: at.width * s,
161
+ height: at.height * s,
162
+ }
163
+ : null,
164
+ );
152
165
  }
153
166
 
154
167
  /**
@@ -248,7 +261,12 @@ export function anchorScreenRect(app, rect, options = {}) {
248
261
  width: (rect.width ?? 0) * s,
249
262
  height: (rect.height ?? 0) * s,
250
263
  };
251
- const area = app ? availableArea(app, { x: anchor.x, y: anchor.y }) : null;
264
+ // The monitor is picked from the whole rect rather than its top-left
265
+ // corner, because a tray item's frame is not inside its own display: a
266
+ // menu-bar button reports a rect that starts a few points above the top
267
+ // edge, and the corner alone lands on whichever display happens to reach
268
+ // up past it — a different one, on a desk with a taller head (#618).
269
+ const area = app ? availableArea(app, anchor) : null;
252
270
  return placeAgainst(anchor, anchor, area, s, options);
253
271
  }
254
272
 
package/src/cocoa/app.js CHANGED
@@ -22,7 +22,7 @@ import { deliverActivate, deliverOpen } from '../application.js';
22
22
  import { flushPendingFrames } from '../frames.js';
23
23
  import { flushSyncWork } from '../priority.js';
24
24
  import { setCompositingForTests } from '../compositing.js';
25
- import { setScreensForTests } from '../screens.js';
25
+ import { setScreenPolling, setScreensForTests } from '../screens.js';
26
26
  import { setScaleForTests } from '../scale.js';
27
27
  import { BezelStore } from './bezels.js';
28
28
  import { CocoaGLArea, cocoaGLConfig, resolveCocoaGLRuntime } from './glarea.js';
@@ -70,6 +70,23 @@ const FRAME_SLACK_MS = 1;
70
70
  // to 100, docs/windows.md §"Resize"). `createRoot({ cocoa: { resizeWait } })`
71
71
  // sets it; 0 is no handshake at all.
72
72
  const RESIZE_WAIT_MS = 50;
73
+ // How often the screen layout is re-read while something is watching it, in
74
+ // ms. There is no event to wait for — the bridge keeps its `NSScreen` copy
75
+ // current on macOS's notification and emits nothing (#617) — so
76
+ // `useScreens()`'s promise to re-render when a monitor is plugged in is
77
+ // kept by asking. 500 is half a second behind a replug, which is under the
78
+ // time it takes to look at the screen, against one `listScreens()` read a
79
+ // second (a lock and a few doubles on a worker, an `NSScreen.screens` walk
80
+ // on the main thread). Only while a component is subscribed:
81
+ // `createRoot({ cocoa: { screenPoll } })` sets it, 0 turns it off, and
82
+ // nothing polls in an app that never calls `useScreens()`.
83
+ const SCREEN_POLL_MS = 500;
84
+ // The floor under a re-read driven by something that is *not* a clock: a
85
+ // window reporting where it is, which a drag reports every frame and a
86
+ // display change reports once. A second is far too long to matter to a
87
+ // person and short enough that a rearrangement is noticed by the time they
88
+ // have finished moving the window (`_recheckScreens`).
89
+ const SCREEN_RECHECK_MS = 1000;
73
90
 
74
91
  export class CocoaApp {
75
92
  constructor(native, options = {}) {
@@ -157,6 +174,26 @@ export class CocoaApp {
157
174
  const screens = native.listScreens();
158
175
  this.scale = screens[0]?.scale ?? 1;
159
176
  this._screens = screens;
177
+ // …and how the layout stays current, because nothing pushes it: the
178
+ // bridge republishes its copy on macOS's own notification but emits no
179
+ // event, so this side asks — before a placement reads the layout, when
180
+ // a window reports a move, and on a clock while `useScreens()` has a
181
+ // subscriber (`refreshScreens`, #617). `_screensAt` is when the last
182
+ // read happened, which is what throttles the ones that are not clocks.
183
+ this._screensAt = performance.now();
184
+ this._screenTimer = null;
185
+ const screenPoll = options.cocoa?.screenPoll ?? SCREEN_POLL_MS;
186
+ if (!(screenPoll >= 0) || !Number.isFinite(screenPoll)) {
187
+ throw new TypeError(
188
+ 'react-x11: cocoa.screenPoll is a number of milliseconds, 0 for ' +
189
+ `none — got ${screenPoll}.`,
190
+ );
191
+ }
192
+ this._screenPoll = screenPoll;
193
+ setScreenPolling(this, {
194
+ revalidate: () => this.refreshScreens(),
195
+ watched: (on) => this._pollScreens(on),
196
+ });
160
197
 
161
198
  // The name the Dock, ⌘-Tab and the menu bar print. An unbundled
162
199
  // process is registered with LaunchServices under its executable —
@@ -504,6 +541,102 @@ export class CocoaApp {
504
541
  this._windows.set(wnd._key, wnd);
505
542
  }
506
543
 
544
+ /**
545
+ * Re-read the screen layout and publish it if the desk has changed — the
546
+ * answer to a monitor plugged in, unplugged, rearranged, woken or made
547
+ * primary (#617). Returns whether anything moved.
548
+ *
549
+ * **`listScreens()` is always current; this side's copy was not.** The
550
+ * bridge republishes its `NSScreen` snapshot on
551
+ * `NSApplicationDidChangeScreenParametersNotification` and answers a
552
+ * pump-mode call from AppKit live — what was read once and kept forever
553
+ * is `_screens`, taken in the constructor. So the whole fix is to ask
554
+ * again, and the read is cheap enough to ask on demand: before a
555
+ * placement clamps a popup to a monitor (`availableArea` through
556
+ * `setScreenPolling`), when a window reports a move (`_recheckScreens`),
557
+ * and on a clock while `useScreens()` is mounted (`_pollScreens`).
558
+ *
559
+ * Public, and the seam for an app that knows before any of those do —
560
+ * `app.refreshScreens()` publishes whatever the OS says right now.
561
+ *
562
+ * **The app's scale is not re-derived.** Every rect this backend speaks
563
+ * in is points × `app.scale`, fixed at startup, and a window's origin,
564
+ * an event's coordinates and a surface's pixels all already exist in
565
+ * that space; moving it under them is a different and much larger change
566
+ * than re-reading a layout. The layout is converted at the scale the rest
567
+ * of the app uses, which is what keeps `monitorAt()` answering with the
568
+ * head a window is actually on (see `screenLayout`).
569
+ */
570
+ refreshScreens() {
571
+ if (this._closed) return false;
572
+ this._screensAt = performance.now();
573
+ let screens = null;
574
+ try {
575
+ screens = this._native.listScreens?.();
576
+ } catch {
577
+ // a bridge going away, or a fake with nothing to say
578
+ return false;
579
+ }
580
+ // An empty answer is "could not tell", never "no displays": a Mac with
581
+ // the lid shut and no panel attached still has the desk it had, and
582
+ // publishing nothing would take every monitor out from under
583
+ // `availableArea` and size the next window against the void.
584
+ if (!screens?.length) return false;
585
+ if (sameScreens(screens, this._screens)) return false;
586
+ this._screens = screens;
587
+ setScreensForTests(this, screenLayout(screens, this.scale));
588
+ // A monitor's refresh rate can change under a window that never moved —
589
+ // a mode switch, a panel woken at 60Hz — and a window's clock is read
590
+ // from this list rather than kept by the display (`frameIntervalFor`).
591
+ for (const wnd of this._windows.values()) {
592
+ if (!wnd.destroyed) wnd._refreshFrameInterval?.();
593
+ }
594
+ return true;
595
+ }
596
+
597
+ /**
598
+ * The layout, re-read at most once every `SCREEN_RECHECK_MS`.
599
+ *
600
+ * For the signals that mean "something about the desk may have moved"
601
+ * rather than "it did": a window reporting its position, which a drag
602
+ * reports every frame and a display change reports once. Plugging a
603
+ * monitor in moves the windows that were on it, so this is the one place
604
+ * the OS does tell us something — it just does not say what.
605
+ */
606
+ _recheckScreens() {
607
+ if (performance.now() - this._screensAt < SCREEN_RECHECK_MS) return false;
608
+ return this.refreshScreens();
609
+ }
610
+
611
+ /**
612
+ * The layout on a clock, while something is subscribed to it.
613
+ *
614
+ * `useScreens()` says it re-renders when a monitor is plugged in or
615
+ * unplugged and when the arrangement changes. On X11 that is a RandR
616
+ * event; here there is nothing to wait for, so while a component is
617
+ * watching, this asks every `cocoa.screenPoll` ms — on an unref'd timer,
618
+ * which never holds the process open and never wakes an app that is
619
+ * waiting on nothing else. An app that never calls `useScreens()` pays
620
+ * nothing at all: the paths where a stale layout is visible ask for
621
+ * themselves.
622
+ *
623
+ * Started and stopped by the session as the first subscriber arrives and
624
+ * the last one leaves (`setScreenPolling`), so a hook that unmounts takes
625
+ * the clock with it.
626
+ */
627
+ _pollScreens(on) {
628
+ if (this._screenTimer) {
629
+ clearInterval(this._screenTimer);
630
+ this._screenTimer = null;
631
+ }
632
+ if (!on || this._closed || !(this._screenPoll > 0)) return;
633
+ this._screenTimer = setInterval(
634
+ () => this.refreshScreens(),
635
+ this._screenPoll,
636
+ );
637
+ this._screenTimer.unref?.();
638
+ }
639
+
507
640
  /**
508
641
  * How often `wnd` may paint, in ms: the explicit `frameInterval` when the
509
642
  * root was given one, else the period of the screen under the window's
@@ -1140,6 +1273,10 @@ export class CocoaApp {
1140
1273
  _routeGeometry(ev) {
1141
1274
  const wnd = this._window(ev);
1142
1275
  if (!wnd || wnd.destroyed) return;
1276
+ // Before the window re-paces itself against the screen list below: a
1277
+ // display plugged in or removed moves the windows that were on it, and
1278
+ // this is the only thing the bridge says about it (`_recheckScreens`).
1279
+ this._recheckScreens();
1143
1280
  wnd._nativeResized(ev);
1144
1281
  wnd.emit('resize', {
1145
1282
  width: wnd.width,
@@ -1375,6 +1512,7 @@ export class CocoaApp {
1375
1512
  this._pump = null;
1376
1513
  if (this._frameTimer) clearTimeout(this._frameTimer);
1377
1514
  this._frameTimer = null;
1515
+ this._pollScreens(false);
1378
1516
  this._cocoaGL?.destroy();
1379
1517
  this._cocoaGL = null;
1380
1518
  this._unsubscribe?.();
@@ -1413,6 +1551,12 @@ export class CocoaApp {
1413
1551
  * *width* as a bound to every other head: a second display wider than the
1414
1552
  * built-in had its right edge pulled in by the difference, and every
1415
1553
  * anchored popup that reached past it was clamped back (issue #453).
1554
+ *
1555
+ * **Everything the bridge says about a screen comes through**, not only its
1556
+ * rects. `primary` and the panel's refresh rate were dropped here, so
1557
+ * `useScreens().primary` read null on macOS — every entry `primary: false`
1558
+ * — and `refreshRate` null beside a `frameIntervalFor` that was pacing
1559
+ * windows on that very number (#617).
1416
1560
  */
1417
1561
  export function screenLayout(screens, scale) {
1418
1562
  const rect = (r) => ({
@@ -1423,9 +1567,17 @@ export function screenLayout(screens, scale) {
1423
1567
  });
1424
1568
  const primary = screens?.[0];
1425
1569
  return {
1426
- monitors: (screens ?? []).map((screen) => ({
1570
+ monitors: (screens ?? []).map((screen, i) => ({
1427
1571
  ...rect(screen),
1428
1572
  ...(screen.visible ? { visible: rect(screen.visible) } : null),
1573
+ // `NSScreen.screens[0]` **is** the primary — the screen with the menu
1574
+ // bar, which is where macOS puts a window that names no position —
1575
+ // and the bridge flags it as well; the index is the same fact for a
1576
+ // bridge that does not.
1577
+ primary: screen.primary ?? i === 0,
1578
+ // `NSScreen.maximumFramesPerSecond`, as `fps`. 0 is the OS declining
1579
+ // to say (before macOS 12), which is `useScreens()`'s null.
1580
+ refreshRate: screen.fps > 0 ? screen.fps : null,
1429
1581
  })),
1430
1582
  // Still published for `useScreens().workArea`, which is one rect for
1431
1583
  // the desktop by definition; the primary's is the closest macOS has.
@@ -1433,6 +1585,48 @@ export function screenLayout(screens, scale) {
1433
1585
  };
1434
1586
  }
1435
1587
 
1588
+ /**
1589
+ * Whether two `listScreens()` answers describe the same desk.
1590
+ *
1591
+ * Every field the layout is built from, in the order they arrived — which
1592
+ * is `NSScreen.screens`, so the order is the arrangement and the primary,
1593
+ * and a change in it is a change. `fps` counts because a window's frame
1594
+ * clock is read from it, and `scale` because a screen that switched mode
1595
+ * is not the screen it was even at the same size.
1596
+ *
1597
+ * Pure, and exported for that reason: it decides whether a re-read
1598
+ * re-renders every `useScreens()` subscriber, and a poll that publishes an
1599
+ * unchanged layout twice a second is a render loop rather than a fix.
1600
+ */
1601
+ export function sameScreens(a, b) {
1602
+ if (!a || !b || a.length !== b.length) return false;
1603
+ for (let i = 0; i < a.length; i++) {
1604
+ const x = a[i];
1605
+ const y = b[i];
1606
+ if (
1607
+ x.x !== y.x ||
1608
+ x.y !== y.y ||
1609
+ x.width !== y.width ||
1610
+ x.height !== y.height ||
1611
+ x.scale !== y.scale ||
1612
+ x.fps !== y.fps ||
1613
+ x.primary !== y.primary ||
1614
+ !sameRect(x.visible, y.visible)
1615
+ ) {
1616
+ return false;
1617
+ }
1618
+ }
1619
+ return true;
1620
+ }
1621
+
1622
+ /** Two `visible` rects, either of which a bridge may not have reported. */
1623
+ function sameRect(a, b) {
1624
+ if (!a || !b) return !a === !b;
1625
+ return (
1626
+ a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
1627
+ );
1628
+ }
1629
+
1436
1630
  /**
1437
1631
  * Build the app and seed the platform stores the way the mock seeds them —
1438
1632
  * `beginScale`/`beginScreens`/`beginCompositing` find a session already
package/src/index.d.ts CHANGED
@@ -233,7 +233,15 @@ export interface RootOptions {
233
233
  * `resizeWait` is how long, in ms, AppKit may hold a live-resize tick for
234
234
  * the app's frame at the new size under `react-x11/cocoa-main`, where
235
235
  * the frame is painted on another thread (50 by default; 0 lets the edge
236
- * move without waiting). `appName` is what the Dock, ⌘-Tab and the app menu print for
236
+ * move without waiting).
237
+ * `screenPoll` is how often the screen layout is re-read while a
238
+ * {@link useScreens} subscriber is mounted, in ms — 500 by default, 0 for
239
+ * never. macOS has no event for a display plugged in, unplugged or
240
+ * rearranged that reaches a client, so a component watching the layout is
241
+ * kept current by asking; an app that never calls `useScreens` never
242
+ * polls, and the paths where a stale layout would misplace a window ask
243
+ * for themselves whatever this says.
244
+ * `appName` is what the Dock, ⌘-Tab and the app menu print for
237
245
  * an unbundled process (a bundle's Info.plist wins); `activationPolicy`
238
246
  * is `'regular'` (a Dock tile, a ⌘-Tab entry — the default),
239
247
  * `'accessory'` (a menu-bar app: windows but no tile) or `'prohibited'`,
@@ -253,6 +261,7 @@ export interface RootOptions {
253
261
  frameInterval?: number;
254
262
  pumpInterval?: number;
255
263
  resizeWait?: number;
264
+ screenPoll?: number;
256
265
  appName?: string;
257
266
  activationPolicy?: 'regular' | 'accessory' | 'prohibited';
258
267
  exitOnQuit?: boolean;
@@ -0,0 +1,47 @@
1
+ // Generated by scripts/keysym-chars.mjs from X11's keysymdef.h — do not edit.
2
+ //
3
+ // The character each legacy keysym produces, run-length encoded: a run is
4
+ // `<keysym>:<codePoint>` in hex, or `<keysym>+<n>:<codePoint>` where the
5
+ // next n keysyms carry the next n code points. `charOf` (src/keysyms.js)
6
+ // expands it once, on first use. One line per keysymdef.h block, because that
7
+ // is how the header is organised and how a regeneration reads as a diff.
8
+ //
9
+ // Latin-1 and the Unicode keysym form are rules rather than entries and are
10
+ // not in here; neither is any keysym whose character is a control character,
11
+ // which is a key that types nothing.
12
+ export const KEYSYM_CHAR_RUNS = [
13
+ // Latin-2 (0x01xx), 57 keysyms
14
+ '1a1:104 1a2:2d8 1a3:141 1a5:13d 1a6:15a 1a9:160 1aa:15e 1ab:164 1ac:179 1ae:17d 1af:17b 1b1:105 1b2:2db 1b3:142 1b5:13e 1b6:15b 1b7:2c7 1b9:161 1ba:15f 1bb:165 1bc:17a 1bd:2dd 1be:17e 1bf:17c 1c0:154 1c3:102 1c5:139 1c6:106 1c8:10c 1ca:118 1cc:11a 1cf:10e 1d0:110 1d1:143 1d2:147 1d5:150 1d8:158 1d9:16e 1db:170 1de:162 1e0:155 1e3:103 1e5:13a 1e6:107 1e8:10d 1ea:119 1ec:11b 1ef:10f 1f0:111 1f1:144 1f2:148 1f5:151 1f8:159 1f9:16f 1fb:171 1fe:163 1ff:2d9',
15
+ // Latin-3 (0x02xx), 22 keysyms
16
+ '2a1:126 2a6:124 2a9:130 2ab:11e 2ac:134 2b1:127 2b6:125 2b9:131 2bb:11f 2bc:135 2c5:10a 2c6:108 2d5:120 2d8:11c 2dd:16c 2de:15c 2e5:10b 2e6:109 2f5:121 2f8:11d 2fd:16d 2fe:15d',
17
+ // Latin-4 (0x03xx), 35 keysyms
18
+ '3a2:138 3a3:156 3a5:128 3a6:13b 3aa:112 3ab:122 3ac:166 3b3:157 3b5:129 3b6:13c 3ba:113 3bb:123 3bc:167 3bd:14a 3bf:14b 3c0:100 3c7:12e 3cc:116 3cf:12a 3d1:145 3d2:14c 3d3:136 3d9:172 3dd:168 3de:16a 3e0:101 3e7:12f 3ec:117 3ef:12b 3f1:146 3f2:14d 3f3:137 3f9:173 3fd:169 3fe:16b',
19
+ // Katakana (0x04xx), 64 keysyms
20
+ '47e:203e 4a1:3002 4a2+1:300c 4a4:3001 4a5:30fb 4a6:30f2 4a7:30a1 4a8:30a3 4a9:30a5 4aa:30a7 4ab:30a9 4ac:30e3 4ad:30e5 4ae:30e7 4af:30c3 4b0:30fc 4b1:30a2 4b2:30a4 4b3:30a6 4b4:30a8 4b5+1:30aa 4b7:30ad 4b8:30af 4b9:30b1 4ba:30b3 4bb:30b5 4bc:30b7 4bd:30b9 4be:30bb 4bf:30bd 4c0:30bf 4c1:30c1 4c2:30c4 4c3:30c6 4c4:30c8 4c5+5:30ca 4cb:30d2 4cc:30d5 4cd:30d8 4ce:30db 4cf+4:30de 4d4:30e4 4d5:30e6 4d6+5:30e8 4dc:30ef 4dd:30f3 4de+1:309b',
21
+ // Arabic (0x05xx), 48 keysyms
22
+ '5ac:60c 5bb:61b 5bf:61f 5c1+25:621 5e0+18:640',
23
+ // Cyrillic (0x06xx), 95 keysyms
24
+ '6a1+1:452 6a3:451 6a4+8:454 6ad:491 6ae+1:45e 6b0:2116 6b1+1:402 6b3:401 6b4+8:404 6bd:490 6be+1:40e 6c0:44e 6c1+1:430 6c3:446 6c4+1:434 6c6:444 6c7:433 6c8:445 6c9+7:438 6d1:44f 6d2+3:440 6d6:436 6d7:432 6d8:44c 6d9:44b 6da:437 6db:448 6dc:44d 6dd:449 6de:447 6df:44a 6e0:42e 6e1+1:410 6e3:426 6e4+1:414 6e6:424 6e7:413 6e8:425 6e9+7:418 6f1:42f 6f2+3:420 6f6:416 6f7:412 6f8:42c 6f9:42b 6fa:417 6fb:428 6fc:42d 6fd:429 6fe:427 6ff:42a',
25
+ // Greek (0x07xx), 71 keysyms
26
+ '7a1:386 7a2+2:388 7a5:3aa 7a7:38c 7a8:38e 7a9:3ab 7ab:38f 7ae:385 7af:2015 7b1+3:3ac 7b5:3ca 7b6:390 7b7+1:3cc 7b9:3cb 7ba:3b0 7bb:3ce 7c1+16:391 7d2:3a3 7d4+5:3a4 7e1+16:3b1 7f2:3c3 7f3:3c2 7f4+5:3c4',
27
+ // Technical (0x08xx), 42 keysyms
28
+ '8a1:23b7 8a2:250c 8a3:2500 8a4+1:2320 8a6:2502 8a7:23a1 8a8+1:23a3 8aa:23a6 8ab:239b 8ac+1:239d 8ae:23a0 8af:23a8 8b0:23ac 8bc:2264 8bd:2260 8be:2265 8bf:222b 8c0:2234 8c1+1:221d 8c5:2207 8c8:223c 8c9:2243 8cd:21d4 8ce:21d2 8cf:2261 8d6:221a 8da+1:2282 8dc+1:2229 8de+1:2227 8ef:2202 8f6:192 8fb+3:2190',
29
+ // Special (0x09xx), 23 keysyms
30
+ '9e0:25c6 9e1:2592 9e2:2409 9e3+1:240c 9e5:240a 9e8:2424 9e9:240b 9ea:2518 9eb:2510 9ec:250c 9ed:2514 9ee:253c 9ef+1:23ba 9f1:2500 9f2+1:23bc 9f4:251c 9f5:2524 9f6:2534 9f7:252c 9f8:2502',
31
+ // Publishing (0x0axx), 80 keysyms
32
+ 'aa1:2003 aa2:2002 aa3+1:2004 aa5+3:2007 aa9:2014 aaa:2013 aac:2423 aae:2026 aaf:2025 ab0+7:2153 ab8:2105 abb:2012 abc:27e8 abd:2e abe:27e9 ac3+3:215b ac9:2122 aca:2613 acc:25c1 acd:25b7 ace:25cb acf:25af ad0+1:2018 ad2+1:201c ad4:211e ad5:2030 ad6+1:2032 ad9:271d adb:25ac adc:25c0 add:25b6 ade:25cf adf:25ae ae0:25e6 ae1:25ab ae2:25ad ae3:25b3 ae4:25bd ae5:2606 ae6:2022 ae7:25aa ae8:25b2 ae9:25bc aea:261c aeb:261e aec:2663 aed:2666 aee:2665 af0:2720 af1+1:2020 af3:2713 af4:2717 af5:266f af6:266d af7:2642 af8:2640 af9:260e afa:2315 afb:2117 afc:2038 afd:201a afe:201e',
33
+ // APL (0x0bxx), 19 keysyms
34
+ 'ba3:3c ba6:3e ba8:2228 ba9:2227 bc0:af bc2:22a4 bc3:2229 bc4:230a bc6:5f bca:2218 bcc:2395 bce:22a5 bcf:25cb bd3:2308 bd6:222a bd8:2283 bda:2282 bdc:22a3 bfc:22a2',
35
+ // Hebrew (0x0cxx), 28 keysyms
36
+ 'cdf:2017 ce0+26:5d0',
37
+ // Thai (0x0dxx), 84 keysyms
38
+ 'da1+57:e01 dde+15:e3e df0+9:e50',
39
+ // Korean (0x0exx), 91 keysyms
40
+ 'ea1+50:3131 ed4+26:11a8 eef:316d ef0:3171 ef1:3178 ef2:317f ef3:3181 ef4:3184 ef5:3186 ef6+1:318d ef8:11eb ef9:11f0 efa:11f9 eff:20a9',
41
+ // Latin-8 and Latin-9 (0x13xx), 3 keysyms
42
+ '13bc+1:152 13be:178',
43
+ // Currency (0x20xx), 1 keysyms
44
+ '20ac:20ac',
45
+ // the keypad (0xffxx), 18 keysyms
46
+ 'ff80:20 ffaa+15:2a ffbd:3d',
47
+ ];
package/src/keysyms.d.ts CHANGED
@@ -9,9 +9,27 @@
9
9
  */
10
10
  export function keysymOf(char: string): number;
11
11
 
12
- /** The character a keysym produces, or `''` for a non-printing key. */
12
+ /**
13
+ * The character a keysym produces, or `''` for a key that types nothing — a
14
+ * modifier, a function key, an arrow, or a dead key waiting for the letter it
15
+ * decorates. Latin-1 and the Unicode form are rules; the legacy blocks a real
16
+ * keymap is written in — Cyrillic, Greek, Latin-2/3/4, Arabic, Hebrew, Thai,
17
+ * the keypad, `EuroSign` — come out of a generated table.
18
+ */
13
19
  export function charOf(keysym: number): string;
14
20
 
21
+ /**
22
+ * The uppercase of a keysym, answered in the spelling the keysym was written
23
+ * in: `й` (`0x6ca`) uppercases to `Й` (`0x6ea`) and not to the Unicode-form
24
+ * spelling of the same letter. A keysym with no case comes back unchanged.
25
+ * This is how Caps Lock capitalises.
26
+ *
27
+ * `'ß'.toUpperCase()` is `'SS'` — two characters, where a key has one to give
28
+ * — so the first code point is what comes back: `S`. Same for `fi` and the
29
+ * polytonic Greek letters whose uppercase is a sequence.
30
+ */
31
+ export function keysymToUpper(keysym: number): number;
32
+
15
33
  /**
16
34
  * The letter of a Ctrl chord, independent of Shift — the keysym for its
17
35
  * lowercase form, so `keysymOf('z')` matches both Ctrl+Z and Ctrl+Shift+Z.
package/src/keysyms.js CHANGED
@@ -5,13 +5,28 @@
5
5
  // `ev.keysym`. The full X11 set is several thousand names; this is the part
6
6
  // a GUI actually handles, plus the rule for everything else.
7
7
  //
8
- // Two facts make the long tail unnecessary:
8
+ // Two rules cover most of it:
9
9
  //
10
10
  // - **Latin-1 is identity.** For U+0020 to U+00FF the keysym *is* the code
11
11
  // point, so `'a'` is `0x61` and `'é'` is `0xe9`. That is the whole ASCII
12
12
  // and Latin-1 range, no table needed.
13
- // - **Everything else is `0x01000000 + codePoint`.** That is the Unicode
14
- // keysym rule, and `keysymOf` below applies both.
13
+ // - **The Unicode form is `0x01000000 + codePoint`.** That is what `keysymOf`
14
+ // below produces for everything outside Latin-1.
15
+ //
16
+ // What the two rules do *not* cover is the **legacy keysym blocks**, and a
17
+ // real keymap is written in them: Cyrillic is `0x6xx`, Greek `0x7xx`,
18
+ // Latin-2/3/4 `0x1xx`–`0x3xx`, Hebrew `0x8xx`, Arabic `0x5xx`, `EuroSign` is
19
+ // `0x20ac` and the keypad digits are `0xffbx`. Those are a table, and it is
20
+ // `src/keysymchars.js` — generated from X11's `keysymdef.h`, which is where
21
+ // libxkbcommon's own table comes from (`scripts/keysym-chars.mjs`).
22
+ //
23
+ // It is only `charOf` that needs it. On X11 ntk supplies the code point and
24
+ // this function is never reached; the Wayland backend decodes the keymap
25
+ // itself (`src/wayland/xkb.js`) and is the first caller that needs `charOf`
26
+ // to be complete, which is why a Russian, Greek or Czech layout typed
27
+ // nothing at all and AltGr+E produced no Euro sign.
28
+
29
+ import { KEYSYM_CHAR_RUNS } from './keysymchars.js';
15
30
 
16
31
  /** The keysym for a single character, by the two rules above. */
17
32
  export function keysymOf(char) {
@@ -21,13 +36,97 @@ export function keysymOf(char) {
21
36
  return 0x01000000 + code;
22
37
  }
23
38
 
24
- /** The character a keysym produces, or `''` for a non-printing key. */
39
+ /**
40
+ * The legacy blocks, expanded once on first use — keysym -> code point, and
41
+ * the way back. The reverse direction is what keeps a case map inside the
42
+ * block it started in: `keysymToUpper(Cyrillic_shorti)` is `Cyrillic_SHORTI`
43
+ * and not the Unicode-form spelling of the same letter, which is the answer
44
+ * a keymap's own keysyms can be compared against.
45
+ */
46
+ let legacyChars;
47
+ let legacyKeysyms;
48
+ function expandLegacy() {
49
+ if (legacyChars) return;
50
+ legacyChars = new Map();
51
+ legacyKeysyms = new Map();
52
+ for (const line of KEYSYM_CHAR_RUNS)
53
+ for (const run of line.split(' ')) {
54
+ const [keysyms, cp] = run.split(':');
55
+ const [first, span] = keysyms.split('+');
56
+ const from = parseInt(first, 16);
57
+ const to = parseInt(cp, 16);
58
+ for (let i = 0; i <= (span ? +span : 0); i++) {
59
+ legacyChars.set(from + i, to + i);
60
+ // The eleven code points two blocks both spell — box drawing, a few
61
+ // set operators, `.` — keep the first, so the answer is stable.
62
+ if (!legacyKeysyms.has(to + i)) legacyKeysyms.set(to + i, from + i);
63
+ }
64
+ }
65
+ }
66
+ function legacyChar(keysym) {
67
+ expandLegacy();
68
+ return legacyChars.get(keysym);
69
+ }
70
+
71
+ /**
72
+ * The character a keysym produces, or `''` for a key that types nothing —
73
+ * a modifier, a function key, an arrow, or a dead key waiting for the letter
74
+ * it decorates.
75
+ *
76
+ * "Types nothing" includes the keys whose code point is a control character:
77
+ * `keysymdef.h` gives BackSpace U+0008 and Delete U+007F, and a text field
78
+ * that inserted those would be inserting a control byte rather than deleting
79
+ * anything. The same goes for the unassigned 0x7f–0x9f stretch of the
80
+ * Latin-1 range, which is not a keysym at all.
81
+ */
25
82
  export function charOf(keysym) {
26
- if (keysym >= 0x20 && keysym <= 0xff) return String.fromCodePoint(keysym);
27
- if (keysym >= 0x01000100 && keysym <= 0x0110ffff) {
28
- return String.fromCodePoint(keysym - 0x01000000);
83
+ if (keysym >= 0x20 && keysym <= 0x7e) return String.fromCodePoint(keysym);
84
+ if (keysym >= 0xa0 && keysym <= 0xff) return String.fromCodePoint(keysym);
85
+ if (keysym >= 0x01000000 && keysym <= 0x0110ffff) {
86
+ const cp = keysym - 0x01000000;
87
+ return cp >= 0x20 && cp !== 0x7f ? String.fromCodePoint(cp) : '';
88
+ }
89
+ const cp = legacyChar(keysym);
90
+ return cp === undefined ? '' : String.fromCodePoint(cp);
91
+ }
92
+
93
+ /**
94
+ * The uppercase of a keysym, staying in the block it came from — `й`
95
+ * (`0x6ca`) uppercases to `Й` (`0x6ea`) and not to the Unicode-form spelling
96
+ * of the same letter. A keysym with no case comes back unchanged.
97
+ *
98
+ * This is how Caps Lock capitalises. It is not a lookup of an uppercase
99
+ * sibling level on the same key: French AZERTY's `é` key is `[é, 2, ~, ˘]`,
100
+ * where level 2 is a digit, and German's AltGr `ſ` has no sibling at all —
101
+ * there is no key anywhere with `ſ` and `S` next to each other.
102
+ *
103
+ * `'ß'.toUpperCase()` is **`'SS'`**, two characters, and a keyboard has one
104
+ * key's worth of character to answer with, so the first code point is what
105
+ * comes back: `S`. libxkbcommon's narrower table says `ẞ` (U+1E9E) there, and
106
+ * the same goes for `ΐ` and `ΰ`, which it leaves alone. Those three are the
107
+ * whole of the disagreement, and `S` is the more useful of the two answers
108
+ * for a key that is about to insert a character.
109
+ */
110
+ export function keysymToUpper(keysym) {
111
+ const ch = charOf(keysym);
112
+ if (!ch) return keysym;
113
+ const upper = ch.toUpperCase();
114
+ if (upper === ch) return keysym;
115
+ const cp = upper.codePointAt(0);
116
+ if (cp === ch.codePointAt(0)) return keysym;
117
+ expandLegacy();
118
+ // Answer in the spelling the keysym was written in. A keymap that uses the
119
+ // legacy blocks gets a legacy keysym back — `й` is `Й` (`0x6ea`), not the
120
+ // Unicode-form spelling of the same letter — and so does Latin-1, where
121
+ // AltGr's `µ` uppercases to `Greek_MU`. A keysym already written in the
122
+ // Unicode form keeps it, because that is the block *it* chose. The two
123
+ // spell the same character either way; what differs is whether the answer
124
+ // can be compared against the keysyms the keymap itself carries.
125
+ if (keysym < 0x01000000) {
126
+ const legacy = legacyKeysyms.get(cp);
127
+ if (legacy !== undefined) return legacy;
29
128
  }
30
- return '';
129
+ return keysymOf(String.fromCodePoint(cp));
31
130
  }
32
131
 
33
132
  // --- editing and navigation ------------------------------------------------
package/src/screens.js CHANGED
@@ -119,6 +119,11 @@ class ScreenSession {
119
119
  this._desktopAtom = null;
120
120
  this._snapshot = null;
121
121
  this._listeners = new Set();
122
+ /** A backend that has to be *asked* for its layout rather than told —
123
+ * see `setScreenPolling`. `_revalidate` re-reads it now; `_watched` is
124
+ * told whether anything is subscribed. */
125
+ this._revalidate = null;
126
+ this._watched = null;
122
127
  /** Every `X.on('event')` handler installed here, so `stop()` can take
123
128
  * them off again rather than leaving one per root on a shared client. */
124
129
  this._handlers = [];
@@ -138,7 +143,11 @@ class ScreenSession {
138
143
  }
139
144
  }
140
145
  this._handlers.length = 0;
146
+ const watched = this._listeners.size > 0;
141
147
  this._listeners.clear();
148
+ if (watched) this._watch(false);
149
+ this._revalidate = null;
150
+ this._watched = null;
142
151
  }
143
152
 
144
153
  /** Install an X event handler that this session owns. */
@@ -183,32 +192,35 @@ class ScreenSession {
183
192
 
184
193
  subscribe(fn) {
185
194
  this._listeners.add(fn);
186
- return () => this._listeners.delete(fn);
195
+ if (this._listeners.size === 1) this._watch(true);
196
+ return () => {
197
+ if (!this._listeners.delete(fn)) return;
198
+ if (!this._listeners.size) this._watch(false);
199
+ };
187
200
  }
188
- }
189
201
 
190
- /** The monitor a point is on, or the largest one when it is on none (a
191
- * window whose owner the WM has not placed yet, or coordinates from a
192
- * screen layout that has since changed). */
193
- function monitorAt(monitors, point) {
194
- if (!monitors?.length) return null;
195
- if (point) {
196
- for (const m of monitors) {
197
- if (
198
- point.x >= m.x &&
199
- point.x < m.x + m.width &&
200
- point.y >= m.y &&
201
- point.y < m.y + m.height
202
- ) {
203
- return m;
204
- }
202
+ /** Ask a pulled backend to re-read the layout, now. Synchronous: the
203
+ * callers are placement paths with no round trip available to them. */
204
+ revalidate() {
205
+ if (!this._revalidate || this.stopped) return;
206
+ try {
207
+ this._revalidate();
208
+ } catch {
209
+ // a backend that cannot answer leaves the layout it published
210
+ // standing, which is a better answer than none
205
211
  }
206
212
  }
207
- let best = monitors[0];
208
- for (const m of monitors) {
209
- if (m.width * m.height > best.width * best.height) best = m;
213
+
214
+ /** Whether anything is subscribed, for a backend that only has to keep
215
+ * asking while someone is listening. */
216
+ _watch(on) {
217
+ if (!this._watched) return;
218
+ try {
219
+ this._watched(on);
220
+ } catch {
221
+ // as above: its clock, its problem
222
+ }
210
223
  }
211
- return best;
212
224
  }
213
225
 
214
226
  /** The overlap of two rects, or `null` where they do not touch. */
@@ -221,6 +233,82 @@ function intersect(a, b) {
221
233
  return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
222
234
  }
223
235
 
236
+ /** How far apart two rects are, squared: zero where they meet, and the gap
237
+ * between their nearest edges otherwise. Squared because nothing compares
238
+ * it against a length — only against another of these. */
239
+ function gapSquared(a, b) {
240
+ const dx = Math.max(a.x - (b.x + b.width), b.x - (a.x + a.width), 0);
241
+ const dy = Math.max(a.y - (b.y + b.height), b.y - (a.y + a.height), 0);
242
+ return dx * dx + dy * dy;
243
+ }
244
+
245
+ /** The biggest monitor there is — the stand-in for "the one you look at",
246
+ * for a question with no position in it at all. */
247
+ function largestMonitor(monitors) {
248
+ let best = monitors[0];
249
+ for (const m of monitors) {
250
+ if (m.width * m.height > best.width * best.height) best = m;
251
+ }
252
+ return best;
253
+ }
254
+
255
+ /**
256
+ * The monitor `near` is on. `near` is a rect in screen coordinates, and a
257
+ * point is the 1x1 rect at it — the same containment a point used to get,
258
+ * since a 1x1 rect overlaps exactly the monitor that contains its corner.
259
+ *
260
+ * **The one it overlaps most**, because one corner of a rect does not say
261
+ * which monitor the rect is on. A menu-bar item's frame starts a few points
262
+ * *above* the top of its own display, and on a desk where another display
263
+ * reaches down past that edge, the corner alone is inside the *other*
264
+ * monitor — or inside none — and the popup opens there (#618). Every rect
265
+ * that has a size knows better than its corner does.
266
+ *
267
+ * **The nearest one**, by the gap between the rects, when it overlaps none.
268
+ * A rect that is off every monitor is nearly always just outside one of
269
+ * them — that same menu-bar furniture, a pointer at the very edge, a window
270
+ * the WM has not placed yet — and the nearest monitor is the only answer
271
+ * that has anything to do with where it was. The largest was the old answer
272
+ * and it can be anywhere on the desk.
273
+ *
274
+ * With no position at all (`near` null — an auto-sized window with no owner
275
+ * to open beside), the largest monitor, which is all there is to go on.
276
+ */
277
+ function monitorAt(monitors, near) {
278
+ if (!monitors?.length) return null;
279
+ if (!near) return largestMonitor(monitors);
280
+ // A degenerate rect counts as its own thinnest real version, the way
281
+ // `anchorOffscreen` reads a caret: a point is 1x1, and so is a rect whose
282
+ // size nobody filled in.
283
+ const rect = {
284
+ x: near.x,
285
+ y: near.y,
286
+ width: near.width > 1 ? near.width : 1,
287
+ height: near.height > 1 ? near.height : 1,
288
+ };
289
+ let best = null;
290
+ let most = 0;
291
+ for (const m of monitors) {
292
+ const over = intersect(m, rect);
293
+ const area = over ? over.width * over.height : 0;
294
+ if (area > most) {
295
+ best = m;
296
+ most = area;
297
+ }
298
+ }
299
+ if (best) return best;
300
+ let nearest = monitors[0];
301
+ let least = Infinity;
302
+ for (const m of monitors) {
303
+ const gap = gapSquared(m, rect);
304
+ if (gap < least) {
305
+ nearest = m;
306
+ least = gap;
307
+ }
308
+ }
309
+ return nearest;
310
+ }
311
+
224
312
  /**
225
313
  * The usable part of one monitor.
226
314
  *
@@ -262,12 +350,19 @@ function usable(monitor, work) {
262
350
 
263
351
  /**
264
352
  * The rect an auto-sized window may grow into, or `null` where there is
265
- * nothing to ask. `near` is a screen-coordinate point the window will open
266
- * next to — a `transientFor` owner's origin, in practice and picks the
267
- * monitor when there are several.
353
+ * nothing to ask. `near` is a screen-coordinate **rect** the window will
354
+ * open against — a `transientFor` owner's origin, the node a popup hangs
355
+ * off, the tray item a click reported — and picks the monitor when there
356
+ * are several (`monitorAt`); `{x, y}` alone is a point.
268
357
  */
269
358
  export function availableArea(app, near = null) {
270
359
  const session = sessions.get(app);
360
+ // The monitor a popup is flipped and clamped into is picked here, so a
361
+ // backend whose layout is pulled rather than pushed is asked *now*
362
+ // rather than answered from whatever it last read (`setScreenPolling`).
363
+ // A rect the desk has since moved lands inside another monitor's stale
364
+ // one, and the popup opens at that monitor's edge (#617).
365
+ session?.revalidate();
271
366
  const screen = session?.screenRect ?? null;
272
367
  if (!session) return screen;
273
368
  const monitor = monitorAt(session.monitors, near) ?? screen;
@@ -364,6 +459,46 @@ export function watchScreens(app, fn) {
364
459
  return session.subscribe(fn);
365
460
  }
366
461
 
462
+ /**
463
+ * Register a backend whose layout has to be **pulled**.
464
+ *
465
+ * X11 and Wayland are told: RandR sends an event, a `wl_output` announces
466
+ * itself, and a `publish` lands from the handler. The cocoa bridge keeps
467
+ * its `NSScreen` copy current on macOS's own
468
+ * `NSApplicationDidChangeScreenParametersNotification` but emits no event
469
+ * for it, so there a display plugged in, rearranged or made primary is a
470
+ * question nobody asked (#617). This is where the asking is wired up:
471
+ *
472
+ * - `revalidate()` re-reads the layout and publishes any change. Called
473
+ * before `availableArea()` picks the monitor a window is sized against
474
+ * or a popup is clamped into, which is where a stale rect does visible
475
+ * damage, and synchronous for that reason.
476
+ * - `watched(on)` is told when the *first* subscriber arrives and when the
477
+ * last one leaves. A change nobody asked about still has to reach
478
+ * `useScreens()`, which needs a clock where there is no event — and a
479
+ * clock that only runs while a component is watching costs an app that
480
+ * never asks nothing at all.
481
+ *
482
+ * Both are optional, and a session with neither behaves exactly as it did:
483
+ * this adds no work to the X11 path.
484
+ */
485
+ export function setScreenPolling(
486
+ app,
487
+ { revalidate = null, watched = null } = {},
488
+ ) {
489
+ let session = sessions.get(app);
490
+ if (!session) {
491
+ session = new ScreenSession(app);
492
+ sessions.set(app, session);
493
+ }
494
+ session._revalidate = revalidate;
495
+ session._watched = watched;
496
+ // Registered after a `useScreens()` already mounted — the backend still
497
+ // has to hear that it is being watched.
498
+ if (watched && session._listeners.size) session._watch(true);
499
+ return session;
500
+ }
501
+
367
502
  // --------------------------------------------------------------------------
368
503
  // Starting up
369
504
  // --------------------------------------------------------------------------
@@ -541,6 +541,11 @@ export interface AcceleratorOptions {
541
541
  * matched against the Latin keysym so a layout switch does not turn it off,
542
542
  * and behind whatever a focused element consumed with `preventDefault()`.
543
543
  * See docs/events.md.
544
+ *
545
+ * With no `scope` the binding belongs to the tree's top-level `<window>`, or,
546
+ * in an app that has none, to the root-level `<popup>` holding the keyboard —
547
+ * a tray popover. One that can reach neither binds nothing and says so once
548
+ * in development.
544
549
  */
545
550
  export function useAccelerator(
546
551
  shortcut: MenuShortcut,
@@ -162,7 +162,9 @@ export interface FileDialogs {
162
162
  *
163
163
  * Exact when the tree has one top-level window, which is nearly every app.
164
164
  * With several it prefers the focused one and warns in development when it
165
- * has to guess — see docs/filedialog.md.
165
+ * has to guess; with none a menu-bar app, which is a tray item and a
166
+ * popover — it answers the root-level `<popup>` that took the keyboard. See
167
+ * docs/filedialog.md.
166
168
  */
167
169
  export declare function useTopLevelWindow(): {
168
170
  readonly current: NtkWindow | DrawnNode | null;
@@ -19,8 +19,10 @@
19
19
  // xkb_symbols `key <AD01> { [ q, Q ] };` keycode -> keysyms per level,
20
20
  // with `symbols[Group2]`/`symbols[2]` and `type=` where given
21
21
  // `modifier_map Mod5 { <LVL3> }` real modifier -> keycodes
22
- // xkb_compat `interpret ISO_Level3_Shift { virtualModifier= LevelThree; }`
23
- // keysym -> virtual modifier
22
+ // xkb_compat `interpret ISO_Level3_Shift { virtualModifier= LevelThree;
23
+ // useModMapMods= level1; }` keysym -> virtual modifier,
24
+ // and which of a key's symbols
25
+ // may name it
24
26
  //
25
27
  // From the last two, a virtual modifier like `LevelThree` resolves to the
26
28
  // real modifier bit the compositor will actually report in
@@ -29,8 +31,10 @@
29
31
  // that does not say.
30
32
  //
31
33
  // What is not read: key actions (the compositor applies those; we only see
32
- // their effect in the modifier state), indicators, geometry, and the
33
- // per-type `preserve` rules.
34
+ // their effect in the modifier state), indicators and geometry. The per-type
35
+ // `preserve` rules *are* read, for the one thing they decide here: whether
36
+ // Caps Lock still has a capitalisation to do after the type has chosen a
37
+ // level (`_capitalises`).
34
38
  //
35
39
  // The output is two things. `keycode2keysyms` is the X core shape —
36
40
  // `[g1l1, g1l2, g2l1, g2l2, …]` — because that is what `keyboard.js`'s
@@ -38,7 +42,7 @@
38
42
  // `decode()` is the full answer, using the key's real type so that level 3
39
43
  // and 4 (AltGr) resolve where the two-level core shape cannot express them.
40
44
 
41
- import { charOf } from '../keysyms.js';
45
+ import { charOf, keysymToUpper } from '../keysyms.js';
42
46
  import { keysymFromName } from './keysymnames.js';
43
47
 
44
48
  /** Real modifier bits, as X and XKB both number them. */
@@ -135,7 +139,7 @@ export class XkbKeymap {
135
139
  while ((m = re.exec(s))) {
136
140
  const body = balanced(s, m.index + m[0].length - 1);
137
141
  re.lastIndex = m.index + m[0].length + body.length;
138
- const type = { name: m[1], mods: [], map: [], levels: 1 };
142
+ const type = { name: m[1], mods: [], map: [], preserve: [], levels: 1 };
139
143
  const mods = body.match(/modifiers\s*=\s*([^;]+);/);
140
144
  if (mods)
141
145
  type.mods = mods[1]
@@ -153,6 +157,17 @@ export class XkbKeymap {
153
157
  type.map.push({ set, level });
154
158
  if (level > type.levels) type.levels = level;
155
159
  }
160
+ // `preserve[Lock+LevelThree]= Lock;` — the modifiers this state does
161
+ // *not* consume, which is the whole of whether Caps Lock still applies
162
+ // on a key whose type otherwise swallows Lock.
163
+ for (const e of body.matchAll(/preserve\[([^\]]+)\]\s*=\s*([^;]+);/gi)) {
164
+ const split = (x) =>
165
+ x
166
+ .split('+')
167
+ .map((y) => y.trim())
168
+ .filter((y) => y && y !== 'none' && y !== 'None');
169
+ type.preserve.push({ set: split(e[1]), kept: split(e[2]) });
170
+ }
156
171
  for (const e of body.matchAll(/level_name\[(?:Level)?(\d+)\]/gi)) {
157
172
  if (+e[1] > type.levels) type.levels = +e[1];
158
173
  }
@@ -161,6 +176,10 @@ export class XkbKeymap {
161
176
  }
162
177
 
163
178
  _parseCompat(s) {
179
+ // `interpret.useModMapMods= AnyLevel;` — the section default, which every
180
+ // keymap states, and which an interpretation overrides for itself.
181
+ const dflt = s.match(/interpret\.useModMapMods\s*=\s*([A-Za-z0-9_]+)/i);
182
+ const level1 = (word) => /^level(?:1|One)$/i.test(word ?? '');
164
183
  // interpret <keysym>[+cond] { virtualModifier= X; ... }
165
184
  const re = /interpret\s+([A-Za-z0-9_]+)(?:\+[^{]*)?\s*\{/g;
166
185
  let m;
@@ -170,7 +189,12 @@ export class XkbKeymap {
170
189
  const vm = body.match(/virtualModifier\s*=\s*([A-Za-z0-9_]+)/i);
171
190
  if (!vm) continue;
172
191
  const sym = keysymFromName(m[1]);
173
- if (sym) (this._interp ??= new Map()).set(sym, vm[1]);
192
+ if (!sym) continue;
193
+ const umm = body.match(/useModMapMods\s*=\s*([A-Za-z0-9_]+)/i);
194
+ (this._interp ??= new Map()).set(sym, {
195
+ vmod: vm[1],
196
+ level1Only: level1(umm ? umm[1] : dflt?.[1]),
197
+ });
174
198
  }
175
199
  }
176
200
 
@@ -256,24 +280,51 @@ export class XkbKeymap {
256
280
  }
257
281
 
258
282
  /**
259
- * Virtual modifier -> real modifier: the keys whose keysym `interpret`
260
- * binds to the virtual modifier are looked up in `modifier_map`, and the
261
- * real bits found there are the answer.
283
+ * Virtual modifier -> real modifier: a key's `interpret`ed keysyms name the
284
+ * virtual modifiers the key sets, and the key's `modifier_map` bits are
285
+ * what those virtual modifiers turn out to mean.
286
+ *
287
+ * Which of a key's keysyms may name one is `useModMapMods`, and it is the
288
+ * whole of AltGr working. `<RALT>` sits in `modifier_map Mod1` beside the
289
+ * other Alt keys and *also* carries `ISO_Level3_Shift`, on a secondary
290
+ * group or a second level:
291
+ *
292
+ * key <RALT> { type= "ONE_LEVEL", symbols[1]= [ Alt_R ],
293
+ * symbols[2]= [ ISO_Level3_Shift ] };
294
+ * modifier_map Mod1 { <LALT>, <RALT>, <ALT>, <META> };
295
+ * modifier_map Mod5 { <LVL3> };
296
+ *
297
+ * Attributing a key's bits to every keysym on it collected Mod1 from
298
+ * `<RALT>` on top of Mod5 from `<LVL3>`, so `LevelThree` resolved to
299
+ * `Mod1|Mod5` — and since `_levelFor` matches the masked state for
300
+ * equality, a real AltGr press (Mod5 alone) never reached level 3. AltGr
301
+ * did nothing and the third and fourth level of every layout were
302
+ * unreachable. `useModMapMods= level1`, which is what the keymap says for
303
+ * `ISO_Level3_Shift`, means a key contributes only where the keysym is its
304
+ * **primary** symbol — group 1, level 1 — and `<RALT>`'s primary symbol is
305
+ * `Alt_R`, so it never should have contributed.
262
306
  */
263
307
  _resolveVmods() {
264
- const bySym = new Map(); // keysym -> real mask, from modmap via symbols
308
+ const bind = (vmod, bits) =>
309
+ this.vmods.set(vmod, (this.vmods.get(vmod) ?? 0) | bits);
265
310
  for (const [code, key] of this.keys) {
266
311
  const bits = this.modmap.get(code);
267
312
  if (!bits) continue;
268
- for (const g of key.groups)
269
- for (const sym of g?.syms ?? [])
270
- if (sym) bySym.set(sym, (bySym.get(sym) ?? 0) | bits);
313
+ for (let gi = 0; gi < key.groups.length; gi++) {
314
+ const syms = key.groups[gi]?.syms ?? [];
315
+ for (let li = 0; li < syms.length; li++) {
316
+ const interp = syms[li] && this._interp?.get(syms[li]);
317
+ if (!interp) continue;
318
+ if (interp.level1Only && (gi || li)) continue;
319
+ bind(interp.vmod, bits);
320
+ }
321
+ }
271
322
  }
272
- for (const { sym, bit } of this._modmapSyms ?? [])
273
- bySym.set(sym, (bySym.get(sym) ?? 0) | bit);
274
- for (const [sym, vmod] of this._interp ?? []) {
275
- const real = bySym.get(sym);
276
- if (real) this.vmods.set(vmod, (this.vmods.get(vmod) ?? 0) | real);
323
+ // `modifier_map Shift { Shift_L };` the bare-keysym form names no key,
324
+ // so there is no level to test it against.
325
+ for (const { sym, bit } of this._modmapSyms ?? []) {
326
+ const interp = this._interp?.get(sym);
327
+ if (interp) bind(interp.vmod, bit);
277
328
  }
278
329
  for (const [name, bit] of Object.entries(VMOD_FALLBACK)) {
279
330
  if (!this.vmods.has(name)) this.vmods.set(name, bit);
@@ -286,6 +337,13 @@ export class XkbKeymap {
286
337
  return this.vmods.get(name) ?? 0;
287
338
  }
288
339
 
340
+ /** A list of modifier names as one real mask. */
341
+ _maskOf(names) {
342
+ let mask = 0;
343
+ for (const name of names) mask |= this._modMask(name);
344
+ return mask;
345
+ }
346
+
289
347
  /**
290
348
  * The X core keyboard mapping — two keysyms per group, up to four groups —
291
349
  * which is what `GetKeyboardMapping` would have answered.
@@ -319,17 +377,37 @@ export class XkbKeymap {
319
377
  */
320
378
  _levelFor(type, mods) {
321
379
  if (!type) return mods & REAL_MODS.Shift ? 1 : 0;
322
- let relevant = 0;
323
- for (const m of type.mods) relevant |= this._modMask(m);
380
+ const relevant = this._maskOf(type.mods);
324
381
  const masked = mods & relevant;
325
382
  for (const { set, level } of type.map) {
326
- let want = 0;
327
- for (const m of set) want |= this._modMask(m);
328
- if (want === masked) return level - 1;
383
+ if (this._maskOf(set) === masked) return level - 1;
329
384
  }
330
385
  return 0;
331
386
  }
332
387
 
388
+ /**
389
+ * Whether Caps Lock still has a capitalisation to do.
390
+ *
391
+ * XKB's rule: Lock is effective, and the key's type did not **consume** it.
392
+ * A type that names Lock among its modifiers consumed it — `ALPHABETIC`'s
393
+ * `map[Lock]= 2` has already picked the level Caps Lock wanted, and
394
+ * capitalising on top would be doing it twice. Unless the type says
395
+ * otherwise: `preserve[Lock+LevelThree]= Lock` hands Lock back, which is how
396
+ * German's AltGr levels capitalise (`ſ` -> `S`) while its Shift levels,
397
+ * reached through `map[Lock]`, do not.
398
+ */
399
+ _capitalises(type, mods) {
400
+ if (!(mods & REAL_MODS.Lock)) return false;
401
+ if (!type) return true;
402
+ const relevant = this._maskOf(type.mods);
403
+ if (!(relevant & REAL_MODS.Lock)) return true;
404
+ const masked = mods & relevant;
405
+ for (const { set, kept } of type.preserve)
406
+ if (this._maskOf(set) === masked)
407
+ return (this._maskOf(kept) & REAL_MODS.Lock) !== 0;
408
+ return false;
409
+ }
410
+
333
411
  /**
334
412
  * Decode a key event.
335
413
  *
@@ -349,31 +427,17 @@ export class XkbKeymap {
349
427
  let level = this._levelFor(g.type, mods);
350
428
  if (level >= g.syms.length || !g.syms[level]) {
351
429
  // Lock on a one-level key, or Shift on a key with no upper level:
352
- // fall back the way the core protocol does the first symbol,
353
- // uppercased when it has a case.
430
+ // fall back the way the core protocol does, to the first symbol. Any
431
+ // capitalisation it has coming is the separate step below.
354
432
  level = 0;
355
433
  }
356
434
  let keysym = g.syms[level] ?? 0;
357
- // Caps Lock on a plain two-level alphabetic key whose type does not
358
- // fold Lock (a keymap with only "TWO_LEVEL" for letters): X core rule.
359
- if (
360
- level === 0 &&
361
- mods & REAL_MODS.Lock &&
362
- g.syms.length >= 2 &&
363
- !(mods & REAL_MODS.Shift)
364
- ) {
365
- const lower = charOf(g.syms[0]);
366
- const upper = charOf(g.syms[1]);
367
- if (
368
- lower &&
369
- upper &&
370
- lower !== upper &&
371
- lower.toUpperCase() === upper &&
372
- g.type?.mods?.includes('Lock') === false
373
- ) {
374
- keysym = g.syms[1];
375
- }
376
- }
435
+ // Caps Lock capitalises the keysym the type already chose, rather than
436
+ // reaching for an uppercase sibling level. Pairing levels works for
437
+ // `[a, A]` and nothing else: AZERTY's `é` key is `[é, 2, ~, ˘]`, where
438
+ // level 2 is a digit, and German's AltGr `ſ` has no sibling at all — so
439
+ // `é`, `à`, `è`, `ç`, `ù` and every Cyrillic letter stayed lowercase.
440
+ if (this._capitalises(g.type, mods)) keysym = keysymToUpper(keysym);
377
441
  if (!keysym) return undefined;
378
442
  const ch = charOf(keysym);
379
443
  const codepoint = ch ? ch.codePointAt(0) : undefined;
@@ -398,24 +462,71 @@ export class XkbKeymap {
398
462
  }
399
463
 
400
464
  /**
401
- * The type XKB assigns a key that names none: one symbol is ONE_LEVEL, two
402
- * are ALPHABETIC when they are a case pair and TWO_LEVEL otherwise, four are
403
- * FOUR_LEVEL(_ALPHABETIC / _SEMIALPHABETIC).
465
+ * `XkbKSIsKeypad`: the one run of keysyms the keypad produces, `KP_Space`
466
+ * through `KP_Equal`. Every keypad key is in it on both of its levels — the
467
+ * navigation keysym (`KP_Home`) as much as the digit (`KP_7`).
468
+ */
469
+ function isKeypad(sym) {
470
+ return sym >= 0xff80 && sym <= 0xffbd;
471
+ }
472
+
473
+ /**
474
+ * `XkbKSIsLower` and `XkbKSIsUpper`: a keysym has a case, and is the lower or
475
+ * the upper one of the pair.
476
+ *
477
+ * Two independent questions rather than one pairing — which is the same
478
+ * mistake `decode()` used to make about Caps Lock, in the one other place it
479
+ * was made. German's `s` is `[s, S, ſ, ẞ]`, and `ſ` does not *pair* with `ẞ`
480
+ * (`'ſ'.toUpperCase()` is `'S'`), but `ſ` is a lowercase letter and `ẞ` is an
481
+ * uppercase one, which is all the type ladder asks. Pairing them made the key
482
+ * FOUR_LEVEL_SEMIALPHABETIC, whose `preserve[Lock+LevelThree]` hands Lock back
483
+ * — so Caps+AltGr capitalised a level that was already capital.
484
+ */
485
+ function isLower(sym) {
486
+ const ch = charOf(sym);
487
+ return !!ch && ch.toLowerCase() === ch && ch.toUpperCase() !== ch;
488
+ }
489
+ function isUpper(sym) {
490
+ const ch = charOf(sym);
491
+ return !!ch && ch.toUpperCase() === ch && ch.toLowerCase() !== ch;
492
+ }
493
+
494
+ /**
495
+ * The type XKB assigns a key that names none — xkbcomp's `FindAutomaticType`,
496
+ * which libxkbcommon inherits. Two symbols are ALPHABETIC when they are a
497
+ * case pair, **KEYPAD when either of them is a keypad keysym**, and TWO_LEVEL
498
+ * otherwise; three or four are the FOUR_LEVEL family along the same ladder.
499
+ *
500
+ * The keypad rung is not decoration: libxkbcommon writes the keypad bare —
501
+ * `key <KP7> { [ KP_Home, KP_7 ] };` — so *every* keypad key lands here, and
502
+ * `[KP_Home, KP_7]` is not a case pair. Without the rung it fell through to
503
+ * TWO_LEVEL, whose `map[Shift]= 2` puts the digit on Shift and leaves NumLock
504
+ * with nothing to do, which inverted the whole keypad: the digits typed
505
+ * `KP_Home`/`KP_Up`/`KP_End` and moved the cursor, and holding Shift is what
506
+ * produced a number. The keymap's own KEYPAD type — `modifiers= Shift+NumLock;
507
+ * map[NumLock]= 2;` — is the one that belongs.
404
508
  */
405
509
  function implicitType(syms) {
406
- const n = syms.filter(Boolean).length;
510
+ // The width is the length of the list with only *trailing* NoSymbols
511
+ // trimmed, which is how libxkbcommon counts it. A NoSymbol in the middle
512
+ // is a level that types nothing, not an absent one: counting the non-zero
513
+ // entries instead made `key <ALT> { [ NoSymbol, Alt_L ] };` a one-level key
514
+ // whose only level was NoSymbol, so <ALT>, <META>, <SUPR> and <HYPR>
515
+ // decoded to nothing in every modifier state.
516
+ let n = syms.length;
517
+ while (n > 0 && !syms[n - 1]) n--;
407
518
  if (n <= 1) return 'ONE_LEVEL';
408
- const casePair = (a, b) => {
409
- const la = charOf(a);
410
- const lb = charOf(b);
411
- return la && lb && la !== lb && la.toUpperCase() === lb;
412
- };
413
- if (n === 2) return casePair(syms[0], syms[1]) ? 'ALPHABETIC' : 'TWO_LEVEL';
414
- if (casePair(syms[0], syms[1]))
415
- return casePair(syms[2], syms[3])
519
+ const cased = (a, b) => isLower(a) && isUpper(b);
520
+ const keypad = isKeypad(syms[0]) || isKeypad(syms[1]);
521
+ if (n === 2) {
522
+ if (cased(syms[0], syms[1])) return 'ALPHABETIC';
523
+ return keypad ? 'KEYPAD' : 'TWO_LEVEL';
524
+ }
525
+ if (cased(syms[0], syms[1]))
526
+ return cased(syms[2], syms[3])
416
527
  ? 'FOUR_LEVEL_ALPHABETIC'
417
528
  : 'FOUR_LEVEL_SEMIALPHABETIC';
418
- return 'FOUR_LEVEL';
529
+ return keypad ? 'FOUR_LEVEL_KEYPAD' : 'FOUR_LEVEL';
419
530
  }
420
531
 
421
532
  /** Everything from `//` or `#` to the end of the line, outside strings. */
package/src/windowid.js CHANGED
@@ -105,8 +105,54 @@ export function topLevelWindows(app) {
105
105
  );
106
106
  }
107
107
 
108
+ /**
109
+ * The root-level `<popup>`s this connection is currently rendering — the
110
+ * half `topLevelWindows()` leaves out, in the order they were added.
111
+ *
112
+ * Only interesting when there are no top-level windows at all: a menu-bar
113
+ * app whose whole UI is a popover a tray click opens has no `<window>` for
114
+ * anything to belong to, and the popup *is* the top of that tree
115
+ * (`useTopLevelWindow`). With a window in the tree these stay out of it, for
116
+ * the reason they are out of `topLevelWindows()` — override-redirect is
117
+ * never what a dialog should be transient for.
118
+ */
119
+ function rootLevelPopups(app) {
120
+ if (!app) return [];
121
+ return (app._rootChildren ?? []).filter(
122
+ (node) => node?.isWindow && node.isPopup && node.window?.id,
123
+ );
124
+ }
125
+
108
126
  let warnedAboutAmbiguity = false;
109
127
 
128
+ /**
129
+ * One of several candidates, on the inference `useTopLevelWindow` documents:
130
+ * one is exact, uniquely focused wins, and anything else is the most
131
+ * recently opened plus a development warning that says so.
132
+ */
133
+ function pickOwner(candidates, what) {
134
+ if (candidates.length <= 1) return candidates[0] ?? null;
135
+
136
+ const focused = candidates.filter((w) => w.events?.windowFocused);
137
+ if (focused.length === 1) return focused[0];
138
+
139
+ // Nothing separates them. `windowFocused` also defaults to true on an
140
+ // ntk too old to report focus changes, so "all of them" is the same
141
+ // answer as "none of them" and both land here.
142
+ if (process.env.NODE_ENV !== 'production' && !warnedAboutAmbiguity) {
143
+ warnedAboutAmbiguity = true;
144
+ console.warn(
145
+ `react-x11: this tree has ${candidates.length} ${what}, none of them ` +
146
+ 'uniquely focused, so the owner window is a guess (the most ' +
147
+ 'recently opened). Pass the window explicitly to be exact:\n' +
148
+ ' const win = useRef(null);\n' +
149
+ ' const { openFile } = useFileDialog({ parentWindow: win });\n' +
150
+ ' return <window ref={win}>…</window>;',
151
+ );
152
+ }
153
+ return candidates[candidates.length - 1];
154
+ }
155
+
110
156
  /**
111
157
  * The window a component belongs to, resolved when it is read.
112
158
  *
@@ -134,6 +180,13 @@ let warnedAboutAmbiguity = false;
134
180
  * - several with nothing to separate them: the most recently opened, and a
135
181
  * development warning naming `parentWindow` as the way to be exact. A
136
182
  * guess that says it is guessing beats a guess that does not.
183
+ * - **no top-level window at all: the root-level `<popup>` that has the
184
+ * keyboard.** A menu-bar app is a tray item and a popover, and nothing
185
+ * else — there is no `<window>` for a shortcut, a file dialog or the
186
+ * global menu to belong to, and answering `null` made every one of them
187
+ * quietly do nothing (issue #616). A `grabKeyboard` popup is where the
188
+ * keys are by construction, so it is preferred over one that is merely
189
+ * up; among equals the same focus/most-recent inference applies.
137
190
  *
138
191
  * Returns a **ref-like object** rather than a number: the window is not
139
192
  * realized on the first render, so a value read then would be `null` on the
@@ -146,27 +199,16 @@ export function useTopLevelWindow() {
146
199
  () => ({
147
200
  get current() {
148
201
  const windows = topLevelWindows(app);
149
- if (windows.length <= 1) return windows[0] ?? null;
150
-
151
- const focused = windows.filter((w) => w.events?.windowFocused);
152
- if (focused.length === 1) return focused[0];
202
+ if (windows.length) return pickOwner(windows, 'top-level windows');
153
203
 
154
- // Nothing separates them. `windowFocused` also defaults to true on an
155
- // ntk too old to report focus changes, so "all of them" is the same
156
- // answer as "none of them" and both land here.
157
- if (process.env.NODE_ENV !== 'production' && !warnedAboutAmbiguity) {
158
- warnedAboutAmbiguity = true;
159
- console.warn(
160
- `react-x11: this tree has ${windows.length} top-level windows and ` +
161
- 'none of them is uniquely focused, so the owner window is a ' +
162
- 'guess (the most recently opened). Pass the window explicitly ' +
163
- 'to be exact:\n' +
164
- ' const win = useRef(null);\n' +
165
- ' const { openFile } = useFileDialog({ parentWindow: win });\n' +
166
- ' return <window ref={win}>…</window>;',
167
- );
168
- }
169
- return windows[windows.length - 1];
204
+ // A popup-only tree the tray popover. Not reached while the app
205
+ // has a window, so nothing that already worked changes shape.
206
+ const popups = rootLevelPopups(app);
207
+ const keyboard = popups.filter((node) => node.props?.grabKeyboard);
208
+ return pickOwner(
209
+ keyboard.length ? keyboard : popups,
210
+ 'root-level popups and no window at all',
211
+ );
170
212
  },
171
213
  }),
172
214
  [app],