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 +1 -1
- package/src/acceleratorhooks.js +40 -6
- package/src/anchor.js +20 -2
- package/src/cocoa/app.js +196 -2
- package/src/index.d.ts +10 -1
- package/src/keysymchars.js +47 -0
- package/src/keysyms.d.ts +19 -1
- package/src/keysyms.js +107 -8
- package/src/screens.js +159 -24
- package/src/types/events.d.ts +5 -0
- package/src/types/filedialog.d.ts +3 -1
- package/src/wayland/xkb.js +170 -59
- package/src/windowid.js +62 -20
package/package.json
CHANGED
package/src/acceleratorhooks.js
CHANGED
|
@@ -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)
|
|
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
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
// - **
|
|
14
|
-
//
|
|
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
|
-
/**
|
|
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 <=
|
|
27
|
-
if (keysym >=
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
208
|
-
for
|
|
209
|
-
|
|
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
|
|
266
|
-
*
|
|
267
|
-
* monitor when there
|
|
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
|
// --------------------------------------------------------------------------
|
package/src/types/events.d.ts
CHANGED
|
@@ -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 —
|
|
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;
|
package/src/wayland/xkb.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
33
|
-
//
|
|
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)
|
|
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:
|
|
260
|
-
*
|
|
261
|
-
*
|
|
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
|
|
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 (
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
for (const
|
|
275
|
-
const
|
|
276
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
353
|
-
//
|
|
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
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
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
|
-
|
|
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
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (
|
|
415
|
-
return
|
|
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
|
|
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
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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],
|