react-x11 2.1.3 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/Reconciler.js +12 -0
- package/src/a11y.js +8 -0
- package/src/appearance.js +11 -0
- package/src/atspi.js +6 -3
- package/src/bus.js +100 -13
- package/src/components/Menu.js +199 -73
- package/src/components/Select.js +0 -1
- package/src/components/anchor.js +19 -10
- package/src/desktopintegration.js +98 -0
- package/src/globalmenu.js +5 -0
- package/src/index.d.ts +37 -0
- package/src/nodes.js +44 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-x11",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "react renderer with X11 as a target",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"files": [
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"node": ">=20.19"
|
|
84
84
|
},
|
|
85
85
|
"dependencies": {
|
|
86
|
-
"ntk": "^8.
|
|
86
|
+
"ntk": "^8.5.0",
|
|
87
87
|
"react-reconciler": "^0.33.0",
|
|
88
88
|
"yoga-layout": "^3.2.1"
|
|
89
89
|
},
|
package/src/Reconciler.js
CHANGED
|
@@ -53,6 +53,7 @@ import { endIdle } from './idle.js';
|
|
|
53
53
|
import { endKeyboardState } from './keyboardstate.js';
|
|
54
54
|
import { endXSettings } from './xsettings.js';
|
|
55
55
|
import { watchAppearance } from './appearance.js';
|
|
56
|
+
import { setDesktopIntegration } from './desktopintegration.js';
|
|
56
57
|
import { ForeignNode } from './foreignnodes.js';
|
|
57
58
|
import { GlAreaNode } from './glnodes.js';
|
|
58
59
|
import { createRegisteredNode, registeredElements } from './registry.js';
|
|
@@ -605,6 +606,12 @@ function watchConnection(app, onDisconnect, deliberate) {
|
|
|
605
606
|
*
|
|
606
607
|
* `display`, `fontSource`, `glxVisual` and `onXError` go straight to ntk.
|
|
607
608
|
* Anything else ntk understands, build the client yourself and pass `app`.
|
|
609
|
+
*
|
|
610
|
+
* `desktop: false` turns off the three things this turns on for you that talk
|
|
611
|
+
* to the session bus — the appearance ladder, the accessibility bridge and
|
|
612
|
+
* the global menu — for an embedder that owns them, or a process that must
|
|
613
|
+
* not fork. `desktop: { appearance: false }` names one. See
|
|
614
|
+
* src/desktopintegration.js and docs/desktop.md.
|
|
608
615
|
*/
|
|
609
616
|
export async function createRoot(options = {}) {
|
|
610
617
|
// Before anything builds a node: every drawn node creates a yoga node in
|
|
@@ -627,6 +634,11 @@ export async function createRoot(options = {}) {
|
|
|
627
634
|
}
|
|
628
635
|
const { app: borrowed, onDisconnect, ...rest } = options;
|
|
629
636
|
const owned = borrowed === undefined;
|
|
637
|
+
// Before anything starts, for two reasons: a bad `desktop` shape must throw
|
|
638
|
+
// with nothing in flight, like the check above it — and `startA11y()` below
|
|
639
|
+
// reads this policy, so it has to be settled before the first await, not
|
|
640
|
+
// after (src/desktopintegration.js).
|
|
641
|
+
setDesktopIntegration(rest.desktop);
|
|
630
642
|
// The connection is started first, and the order is the point rather than a
|
|
631
643
|
// detail. `loadLayout()` is only nominally asynchronous: instantiating the
|
|
632
644
|
// engine blocks the event loop for 15-50 ms before it returns its promise
|
package/src/a11y.js
CHANGED
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
// from gi.repository import Atspi; \
|
|
41
41
|
// print({k: int(getattr(Atspi.Role, k)) for k in dir(Atspi.Role) if not k.startswith('_')})"
|
|
42
42
|
|
|
43
|
+
// The one import, and it keeps this file's promise: a `Set` and two functions
|
|
44
|
+
// over it, with no D-Bus, no node builtins and nothing at module scope.
|
|
45
|
+
import { desktopIntegrationEnabled } from './desktopintegration.js';
|
|
46
|
+
|
|
43
47
|
/** AT-SPI role numbers (AtspiRole). */
|
|
44
48
|
export const ATSPI_ROLE = Object.freeze({
|
|
45
49
|
INVALID: 0,
|
|
@@ -1245,6 +1249,10 @@ let startPromise = null;
|
|
|
1245
1249
|
* tests are exactly this case.
|
|
1246
1250
|
*/
|
|
1247
1251
|
function a11yEnabled() {
|
|
1252
|
+
// `createRoot({ desktop: false })` outranks every environment variable,
|
|
1253
|
+
// including the one that forces the climb: it is the embedder saying this
|
|
1254
|
+
// process does not talk to the desktop (src/desktopintegration.js, #417).
|
|
1255
|
+
if (!desktopIntegrationEnabled('a11y')) return false;
|
|
1248
1256
|
const own = process.env.REACT_X11_A11Y;
|
|
1249
1257
|
if (own === '0') return false;
|
|
1250
1258
|
if (own) return true;
|
package/src/appearance.js
CHANGED
|
@@ -57,6 +57,7 @@ import os from 'node:os';
|
|
|
57
57
|
import path from 'node:path';
|
|
58
58
|
|
|
59
59
|
import { sessionBus } from './bus.js';
|
|
60
|
+
import { desktopIntegrationEnabled } from './desktopintegration.js';
|
|
60
61
|
import { PORTAL_NAME, PORTAL_PATH } from './portal.js';
|
|
61
62
|
import { beginXSettings, watchXSettings, xsettings } from './xsettings.js';
|
|
62
63
|
|
|
@@ -238,6 +239,12 @@ function sanitize(saved) {
|
|
|
238
239
|
function load() {
|
|
239
240
|
if (cacheChecked) return;
|
|
240
241
|
cacheChecked = true;
|
|
242
|
+
// `createRoot({ desktop: false })` means this process does not follow the
|
|
243
|
+
// desktop, and that has to include the remembered answer: seeding from the
|
|
244
|
+
// cache would leave an app that opted out drawing in whatever colours this
|
|
245
|
+
// machine happened to be in last time, which is the opposite of the
|
|
246
|
+
// determinism the switch is asked for (#417).
|
|
247
|
+
if (!desktopIntegrationEnabled('appearance')) return;
|
|
241
248
|
const file = cacheFile();
|
|
242
249
|
if (!file) return;
|
|
243
250
|
try {
|
|
@@ -702,6 +709,10 @@ async function runLadder(app) {
|
|
|
702
709
|
*/
|
|
703
710
|
export function systemAppearance(options = {}) {
|
|
704
711
|
if (owner) return Promise.resolve(snapshot);
|
|
712
|
+
// Turned off, so there is nothing to climb and nothing to remember: the
|
|
713
|
+
// defaults, which is a real answer — `'no-preference'` means *use your own*
|
|
714
|
+
// (src/desktopintegration.js).
|
|
715
|
+
if (!desktopIntegrationEnabled('appearance')) return Promise.resolve(NOTHING);
|
|
705
716
|
load();
|
|
706
717
|
if (!probe) {
|
|
707
718
|
// **Failure is not cached**, for the same reason `bus.js` does not cache
|
package/src/atspi.js
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
// resurrected: the slots are cleared and the app simply stops being
|
|
33
33
|
// accessible until restarted, the same contract bus.js documents.
|
|
34
34
|
|
|
35
|
-
import {
|
|
35
|
+
import { loadTransport, resolveAddress } from './bus.js';
|
|
36
36
|
import {
|
|
37
37
|
hooks,
|
|
38
38
|
ATSPI_ROLE,
|
|
@@ -1818,8 +1818,11 @@ async function connected(bus) {
|
|
|
1818
1818
|
*/
|
|
1819
1819
|
async function accessibilityBusAddress(dbus) {
|
|
1820
1820
|
if (process.env.AT_SPI_BUS_ADDRESS) return process.env.AT_SPI_BUS_ADDRESS;
|
|
1821
|
-
|
|
1822
|
-
|
|
1821
|
+
// Resolved, not read: on macOS this is where the session bus address comes
|
|
1822
|
+
// from, and letting `dbus-native` find it for itself would be a blocking
|
|
1823
|
+
// `spawnSync` on the way to the first frame (#417).
|
|
1824
|
+
const busAddress = await resolveAddress('session');
|
|
1825
|
+
if (!busAddress) return null;
|
|
1823
1826
|
let sbus = null;
|
|
1824
1827
|
try {
|
|
1825
1828
|
sbus = dbus.createClient({ busAddress });
|
package/src/bus.js
CHANGED
|
@@ -135,9 +135,8 @@ export async function loadTransport() {
|
|
|
135
135
|
* address is incoherent under sharing. Two callers, two addresses, one
|
|
136
136
|
* socket — which would win? Tests use this same seam.
|
|
137
137
|
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* the note in `accessibilityBusAddress`.
|
|
138
|
+
* Synchronous, and so it cannot answer on macOS — see `resolveAddress`,
|
|
139
|
+
* which is what every dial goes through.
|
|
141
140
|
*/
|
|
142
141
|
export function addressFor(kind) {
|
|
143
142
|
if (kind === 'system') {
|
|
@@ -149,21 +148,102 @@ export function addressFor(kind) {
|
|
|
149
148
|
if (process.env.DBUS_SESSION_BUS_ADDRESS) {
|
|
150
149
|
return process.env.DBUS_SESSION_BUS_ADDRESS;
|
|
151
150
|
}
|
|
152
|
-
// macOS advertises the session bus through launchd
|
|
153
|
-
//
|
|
151
|
+
// macOS advertises the session bus through launchd rather than through the
|
|
152
|
+
// environment, and asking launchd is a subprocess — see `launchdAddress`.
|
|
154
153
|
if (process.platform === 'darwin') return undefined;
|
|
155
154
|
const runtimeDir = process.env.XDG_RUNTIME_DIR;
|
|
156
155
|
return runtimeDir ? `unix:path=${runtimeDir}/bus` : undefined;
|
|
157
156
|
}
|
|
158
157
|
|
|
158
|
+
/**
|
|
159
|
+
* The in-flight or settled lookup, shared by every dial in the process.
|
|
160
|
+
*
|
|
161
|
+
* **Failure is cached here**, which is the one place in this module where it
|
|
162
|
+
* is — and the exception is deliberate. A session bus can genuinely appear
|
|
163
|
+
* under `$XDG_RUNTIME_DIR` mid-run, so that answer is never remembered; what
|
|
164
|
+
* launchd exports is a *login session* fact, set when the bus is installed
|
|
165
|
+
* and started, which does not happen under a running app. Re-asking would be
|
|
166
|
+
* a fork per feature probe to learn the same "no".
|
|
167
|
+
*/
|
|
168
|
+
let launchd = null;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Ask launchd where the session bus is, **without blocking the event loop**.
|
|
172
|
+
*
|
|
173
|
+
* This is the whole reason `resolveAddress` exists (#417). `dbus-native` will
|
|
174
|
+
* do this lookup itself, from `createStream`, with `spawnSync` — it has to,
|
|
175
|
+
* because its own entry point is synchronous — and a fork+exec of `launchctl`
|
|
176
|
+
* on a cold page cache costs 120–150 ms of *blocked loop*, measured. Landing
|
|
177
|
+
* that inside `createRoot()` stalls the X handshake, the yoga instantiate and
|
|
178
|
+
* the first paint behind a question about the colour scheme.
|
|
179
|
+
*
|
|
180
|
+
* Every caller here is already asynchronous, so the same lookup done with
|
|
181
|
+
* `execFile` costs the same wall clock and none of the loop: the handshake
|
|
182
|
+
* and the layout engine run through it. Handing `dbus-native` the resolved
|
|
183
|
+
* `unix:path=…` is then what keeps it off its own synchronous path.
|
|
184
|
+
*
|
|
185
|
+
* The variable name is D-Bus's, and the fallback to our own environment is
|
|
186
|
+
* `launchdSocketPath`'s: a process launched from a shell that has it already
|
|
187
|
+
* knows the answer.
|
|
188
|
+
*/
|
|
189
|
+
function launchdAddress() {
|
|
190
|
+
if (launchd) return launchd;
|
|
191
|
+
launchd = (async () => {
|
|
192
|
+
const VAR = 'DBUS_LAUNCHD_SESSION_BUS_SOCKET';
|
|
193
|
+
let fromLaunchd = '';
|
|
194
|
+
try {
|
|
195
|
+
const { execFile } = await import('node:child_process');
|
|
196
|
+
fromLaunchd = await new Promise((resolve) => {
|
|
197
|
+
execFile(
|
|
198
|
+
'launchctl',
|
|
199
|
+
['getenv', VAR],
|
|
200
|
+
{ encoding: 'utf8' },
|
|
201
|
+
(err, stdout) => resolve(err ? '' : String(stdout).trim()),
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
} catch {
|
|
205
|
+
// no `launchctl` on $PATH, or no child processes to be had at all
|
|
206
|
+
}
|
|
207
|
+
const socket = fromLaunchd || process.env[VAR] || '';
|
|
208
|
+
return socket ? `unix:path=${socket}` : undefined;
|
|
209
|
+
})();
|
|
210
|
+
return launchd;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Where to dial, resolved — the synchronous sources first, and launchd only
|
|
215
|
+
* where they have nothing to say.
|
|
216
|
+
*
|
|
217
|
+
* Not public, but exported for atspi.js, whose one-shot discovery probe
|
|
218
|
+
* dials its own short-lived connection rather than the shared one — see the
|
|
219
|
+
* note in `accessibilityBusAddress`. It matters that it goes through here
|
|
220
|
+
* too: two dials that each let `dbus-native` ask launchd are two blocking
|
|
221
|
+
* forks, and the answer is the same both times.
|
|
222
|
+
*
|
|
223
|
+
* @param {BusKind} kind
|
|
224
|
+
* @returns {Promise<string | undefined>}
|
|
225
|
+
*/
|
|
226
|
+
export async function resolveAddress(kind) {
|
|
227
|
+
const direct = addressFor(kind);
|
|
228
|
+
if (direct !== undefined) return direct;
|
|
229
|
+
if (kind !== 'session' || process.platform !== 'darwin') return undefined;
|
|
230
|
+
return await launchdAddress();
|
|
231
|
+
}
|
|
232
|
+
|
|
159
233
|
function noAddressError(kind) {
|
|
234
|
+
if (kind !== 'session') {
|
|
235
|
+
return new Error(
|
|
236
|
+
'react-x11: no system bus address. $DBUS_SYSTEM_BUS_ADDRESS is unset ' +
|
|
237
|
+
'and there is no default.',
|
|
238
|
+
);
|
|
239
|
+
}
|
|
160
240
|
return new Error(
|
|
161
|
-
|
|
162
|
-
(
|
|
163
|
-
? '
|
|
164
|
-
'
|
|
165
|
-
|
|
166
|
-
|
|
241
|
+
'react-x11: no session bus address. $DBUS_SESSION_BUS_ADDRESS is unset ' +
|
|
242
|
+
(process.platform === 'darwin'
|
|
243
|
+
? 'and launchd has no $DBUS_LAUNCHD_SESSION_BUS_SOCKET either, which ' +
|
|
244
|
+
'is normal on a Mac with no D-Bus installed.'
|
|
245
|
+
: 'and $XDG_RUNTIME_DIR is not set either, which is normal over ssh, ' +
|
|
246
|
+
'under a bare startx and in most containers.'),
|
|
167
247
|
);
|
|
168
248
|
}
|
|
169
249
|
|
|
@@ -216,8 +296,11 @@ async function connect(kind, generation) {
|
|
|
216
296
|
return fail(noTransportError(cause));
|
|
217
297
|
}
|
|
218
298
|
|
|
219
|
-
|
|
220
|
-
|
|
299
|
+
// Resolved rather than read, so `dbus-native` never reaches its own
|
|
300
|
+
// `spawnSync` fallback on macOS (#417) — and so "this Mac has no D-Bus"
|
|
301
|
+
// fails here, before a socket is dialled, instead of inside a constructor.
|
|
302
|
+
const busAddress = await resolveAddress(kind);
|
|
303
|
+
if (!busAddress) {
|
|
221
304
|
return fail(noAddressError(kind));
|
|
222
305
|
}
|
|
223
306
|
|
|
@@ -538,6 +621,10 @@ export function busRefs(kind) {
|
|
|
538
621
|
* exported object survives into the next test.
|
|
539
622
|
*/
|
|
540
623
|
export function _resetBusState() {
|
|
624
|
+
// Including what launchd said: a suite that pins `process.platform` would
|
|
625
|
+
// otherwise inherit the answer — or the absence of one — from a case that
|
|
626
|
+
// ran under a different one.
|
|
627
|
+
launchd = null;
|
|
541
628
|
for (const kind of KINDS) {
|
|
542
629
|
state[kind] = newState(kind, state[kind].generation + 1);
|
|
543
630
|
notify(kind);
|
package/src/components/Menu.js
CHANGED
|
@@ -59,7 +59,12 @@ const h = React.createElement;
|
|
|
59
59
|
const MENU_ITEM_PAD = 8;
|
|
60
60
|
const menuRowHeight = (fontSize) => capBand(fontSize) + MENU_ITEM_PAD * 2;
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
// A separator is the hairline plus the air that makes it a division rather
|
|
63
|
+
// than a row with a line through it — the two groups it stands between have
|
|
64
|
+
// to read as apart, and at three pixels a side the line just crowds the
|
|
65
|
+
// labels above and below it.
|
|
66
|
+
const MENU_SEPARATOR_PAD = 5;
|
|
67
|
+
const MENU_SEPARATOR_HEIGHT = MENU_SEPARATOR_PAD * 2 + 1;
|
|
63
68
|
|
|
64
69
|
const MENU_MIN_WIDTH = 140;
|
|
65
70
|
|
|
@@ -76,28 +81,47 @@ const MENU_PAD = 4;
|
|
|
76
81
|
// borders on its *controls* does not mean a 2px outline around every menu.
|
|
77
82
|
const MENU_BORDER = 1;
|
|
78
83
|
|
|
79
|
-
// How far a bar item's pill sits inside the bar, taken out of its padding so
|
|
80
|
-
// the bar's height does not change. The strip carries the same inset at its
|
|
81
|
-
// two ends: a pill that starts in the window's own corner reads as part of
|
|
82
|
-
// the frame rather than as something on a strip, and the first menu is the
|
|
83
|
-
// one every pointer arrives at.
|
|
84
|
-
const BAR_INSET = 3;
|
|
85
|
-
// the gap between two pills, split between them
|
|
86
|
-
const BAR_GAP = 1;
|
|
87
|
-
|
|
88
84
|
// A bar item wears the same pill as a row in the menu it opens, so it takes
|
|
89
85
|
// the row's padding rather than numbers of its own: a title packed tighter
|
|
90
86
|
// than its own first row is the tell that the two were measured separately.
|
|
91
87
|
// Vertically that is `MENU_ITEM_PAD` exactly — same padding, same `capTrim`
|
|
92
|
-
// text — which makes the pill `menuRowHeight` tall
|
|
93
|
-
//
|
|
88
|
+
// text — which makes the pill `menuRowHeight` tall and the strip exactly
|
|
89
|
+
// that: a menu bar is one row, and its highlight fills it top to bottom the
|
|
90
|
+
// way a real one does. Inset the pill instead and the bar is a row plus two
|
|
91
|
+
// margins tall, with a highlight floating in a band of leftover strip.
|
|
92
|
+
//
|
|
93
|
+
// Horizontally it takes a little more, and the number is measured rather
|
|
94
|
+
// than argued: the bar this one is imitating leaves 22px between the ink of
|
|
95
|
+
// two titles, so that is what a title's own padding and its neighbour's have
|
|
96
|
+
// to add up to. A row's label in a menu with a check column is
|
|
97
|
+
// `MENU_ITEM_PAD + MENU_GUTTER` from the pill's edge anyway, so the row's own
|
|
98
|
+
// padding would read as the cramped one here — a title has no column to sit
|
|
99
|
+
// past, but it does sit shoulder to shoulder with the next title.
|
|
94
100
|
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
// between
|
|
100
|
-
|
|
101
|
+
const BAR_ITEM_PAD_X = MENU_ITEM_PAD + 3;
|
|
102
|
+
|
|
103
|
+
// The pill is wider than the item that owns it. Measured off the same bar:
|
|
104
|
+
// its highlight runs 16px past the ink of the title it belongs to, which is
|
|
105
|
+
// five past the halfway line between that title and the next — so a lit
|
|
106
|
+
// title reaches *into* both neighbours' halves of the strip rather than
|
|
107
|
+
// stopping politely at the boundary. It reads as one object sitting on the
|
|
108
|
+
// bar; a pill that stops at the midpoint reads as one cell of a table that
|
|
109
|
+
// happens to be filled in.
|
|
110
|
+
//
|
|
111
|
+
// Drawn, not laid out. The item box still tiles the strip, so the 22px
|
|
112
|
+
// between two titles is still two paddings and the pointer still belongs to
|
|
113
|
+
// exactly one item everywhere along the bar. Widening the boxes to the size
|
|
114
|
+
// of the pill instead would overlap them by ten pixels, and then the last
|
|
115
|
+
// five of a title's own highlight would open its neighbour's menu.
|
|
116
|
+
const BAR_PILL_BLEED = 5;
|
|
117
|
+
|
|
118
|
+
// What is left of the strip at its two ends, before the first pill and after
|
|
119
|
+
// the last: a highlight cut off square by the window's edge is the one place
|
|
120
|
+
// a pill has no room to be a pill. Measured like the rest — a native bar
|
|
121
|
+
// leaves five — and the strip's padding is that plus the bleed, since the
|
|
122
|
+
// pill starts before its item does.
|
|
123
|
+
const BAR_END_INSET = 5;
|
|
124
|
+
const BAR_END_PAD = BAR_END_INSET + BAR_PILL_BLEED;
|
|
101
125
|
|
|
102
126
|
// The bar entry that stands for the titles that did not fit. A symbol rather
|
|
103
127
|
// than a `label`, because it is the one entry on the bar the application did
|
|
@@ -119,9 +143,29 @@ const OVERFLOW_KEY = '\0menubar-overflow';
|
|
|
119
143
|
// way (`movingToward`), so this is a matter of taste rather than of reach.
|
|
120
144
|
const SUBMENU_GAP = 0;
|
|
121
145
|
|
|
122
|
-
|
|
123
|
-
// what a self-drawing icon gets to fill, inside that column's 16px
|
|
146
|
+
// what a self-drawing icon gets to fill
|
|
124
147
|
const MENU_ICON_SIZE = 12;
|
|
148
|
+
// The air between that mark and the label it belongs to. A mark set against
|
|
149
|
+
// its label with a pixel or two to spare reads as part of the word rather
|
|
150
|
+
// than as a column of its own — but the column is an indent every label in
|
|
151
|
+
// the menu pays for, so it is the smallest gap that still reads as one:
|
|
152
|
+
// enough to separate two things, less than the space between two words.
|
|
153
|
+
const MENU_ICON_GAP = 6;
|
|
154
|
+
// The check column: the mark's own box plus that gap. The mark starts at the
|
|
155
|
+
// row's padding — flush with where a label starts in a menu that has no
|
|
156
|
+
// column at all — so the whole of the column's width is the space after it.
|
|
157
|
+
const MENU_GUTTER = MENU_ICON_SIZE + MENU_ICON_GAP;
|
|
158
|
+
|
|
159
|
+
// Menu text sits a step above the body weight. A menu is read in glances
|
|
160
|
+
// rather than in sentences — a title on a strip, a row under the pointer —
|
|
161
|
+
// and the native ones are all set a shade heavier for it. Where the face has
|
|
162
|
+
// a medium this picks it; where it has only a regular and a bold, 500 is
|
|
163
|
+
// nearer the regular and nothing changes, which is the right failure.
|
|
164
|
+
//
|
|
165
|
+
// Every label measured for a popup's width is measured at this weight too
|
|
166
|
+
// (`measureLabel` takes it), since a menu sized in regular for rows drawn in
|
|
167
|
+
// medium is a menu whose own labels run into its shortcuts.
|
|
168
|
+
const MENU_TEXT_WEIGHT = 500;
|
|
125
169
|
|
|
126
170
|
const MENU_SHORTCUT_GAP = 24;
|
|
127
171
|
// menus size to their content rather than scrolling, so a page is a fixed
|
|
@@ -138,6 +182,37 @@ function menuListHeight(items, fontSize) {
|
|
|
138
182
|
return body + (MENU_PAD + MENU_BORDER) * 2;
|
|
139
183
|
}
|
|
140
184
|
|
|
185
|
+
/**
|
|
186
|
+
* The width of this menu's check column, which is `0` for a menu with
|
|
187
|
+
* nothing to draw in it.
|
|
188
|
+
*
|
|
189
|
+
* The question is asked of the level rather than of the row — one mark
|
|
190
|
+
* anywhere in a menu indents every label in it, which is what puts the
|
|
191
|
+
* labels in a column of their own — and what counts is what is *drawn*, not
|
|
192
|
+
* what could be. A checkbox that is off draws nothing, so a menu of unticked
|
|
193
|
+
* toggles is a menu of plain commands as far as the eye is concerned, and it
|
|
194
|
+
* gets the plain menu's left edge.
|
|
195
|
+
*
|
|
196
|
+
* The cost is the one every toolkit reserving this column is avoiding:
|
|
197
|
+
* ticking the first item in such a menu moves its labels sideways. That is
|
|
198
|
+
* the trade this codebase makes deliberately — an indent on every menu with
|
|
199
|
+
* a toggle anywhere in it, paid by every label in it, buys stillness in the
|
|
200
|
+
* one frame where something is ticked. A menu that already has a mark keeps
|
|
201
|
+
* the column whatever happens to the rest, so the movement is once at the
|
|
202
|
+
* boundary rather than on every tick.
|
|
203
|
+
*
|
|
204
|
+
* `toggleState` is dbusmenu's three-state one: `-1` draws a dash and counts,
|
|
205
|
+
* `0` draws nothing and does not. A separator answers for nothing either way,
|
|
206
|
+
* since it spans the row.
|
|
207
|
+
*/
|
|
208
|
+
function menuGutter(items) {
|
|
209
|
+
const wanted = visibleItems(items).some(
|
|
210
|
+
(item) =>
|
|
211
|
+
!isSeparator(item) && (toggleMark(item) != null || item.icon != null),
|
|
212
|
+
);
|
|
213
|
+
return wanted ? MENU_GUTTER : 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
141
216
|
/** Widest label + shortcut, measured, so the popup can be sized up front. */
|
|
142
217
|
function menuListWidth(node, items, fontSize) {
|
|
143
218
|
let widest = 0;
|
|
@@ -145,20 +220,23 @@ function menuListWidth(node, items, fontSize) {
|
|
|
145
220
|
if (isSeparator(item)) continue;
|
|
146
221
|
const label = measureLabel(node, item.label ?? '', {
|
|
147
222
|
size: fontSize,
|
|
223
|
+
weight: MENU_TEXT_WEIGHT,
|
|
148
224
|
}).width;
|
|
149
225
|
const accelerator = formatShortcut(item.shortcut);
|
|
150
226
|
const shortcut = accelerator
|
|
151
|
-
? measureLabel(node, accelerator, {
|
|
152
|
-
|
|
227
|
+
? measureLabel(node, accelerator, {
|
|
228
|
+
size: fontSize,
|
|
229
|
+
weight: MENU_TEXT_WEIGHT,
|
|
230
|
+
}).width + MENU_SHORTCUT_GAP
|
|
153
231
|
: 0;
|
|
154
232
|
widest = Math.max(widest, label + shortcut);
|
|
155
233
|
}
|
|
156
234
|
return Math.max(
|
|
157
235
|
MENU_MIN_WIDTH,
|
|
158
236
|
Math.ceil(widest) +
|
|
159
|
-
|
|
237
|
+
menuGutter(items) +
|
|
160
238
|
(MENU_PAD + MENU_BORDER) * 2 +
|
|
161
|
-
MENU_ITEM_PAD +
|
|
239
|
+
MENU_ITEM_PAD * 2 +
|
|
162
240
|
2,
|
|
163
241
|
);
|
|
164
242
|
}
|
|
@@ -169,13 +247,15 @@ function menuListWidth(node, items, fontSize) {
|
|
|
169
247
|
* `menuListWidth` so the two cannot drift.
|
|
170
248
|
*/
|
|
171
249
|
function barItemWidth(node, label, fontSize) {
|
|
172
|
-
const text = measureLabel(node, label ?? '', {
|
|
173
|
-
|
|
250
|
+
const text = measureLabel(node, label ?? '', {
|
|
251
|
+
size: fontSize,
|
|
252
|
+
weight: MENU_TEXT_WEIGHT,
|
|
253
|
+
}).width;
|
|
254
|
+
return Math.ceil(text) + BAR_ITEM_PAD_X * 2;
|
|
174
255
|
}
|
|
175
256
|
|
|
176
257
|
/** The same, for the chevron: an icon box where a title has its label. */
|
|
177
|
-
const barOverflowWidth = (fontSize) =>
|
|
178
|
-
iconSize(fontSize) + (BAR_ITEM_PAD_X + BAR_GAP) * 2;
|
|
258
|
+
const barOverflowWidth = (fontSize) => iconSize(fontSize) + BAR_ITEM_PAD_X * 2;
|
|
179
259
|
|
|
180
260
|
/**
|
|
181
261
|
* How many titles the bar can paint, and therefore where it is cut. The rest
|
|
@@ -195,7 +275,7 @@ const barOverflowWidth = (fontSize) =>
|
|
|
195
275
|
*/
|
|
196
276
|
function barCut(node, menus, fontSize, width) {
|
|
197
277
|
const widths = menus.map((menu) => barItemWidth(node, menu.label, fontSize));
|
|
198
|
-
const inner = width -
|
|
278
|
+
const inner = width - BAR_END_PAD * 2;
|
|
199
279
|
const total = widths.reduce((sum, w) => sum + w, 0);
|
|
200
280
|
if (total <= inner) return menus.length;
|
|
201
281
|
const room = inner - barOverflowWidth(fontSize);
|
|
@@ -314,6 +394,10 @@ function MenuRow({
|
|
|
314
394
|
onMove,
|
|
315
395
|
onSelect,
|
|
316
396
|
fontSize,
|
|
397
|
+
// The width of the level's check column — the same number the popup was
|
|
398
|
+
// sized with, passed down rather than asked of the item, since a row with
|
|
399
|
+
// no mark of its own still keeps the column its neighbours need.
|
|
400
|
+
gutter,
|
|
317
401
|
nodeRef,
|
|
318
402
|
}) {
|
|
319
403
|
const theme = useTheme();
|
|
@@ -369,7 +453,6 @@ function MenuRow({
|
|
|
369
453
|
alignItems: 'center',
|
|
370
454
|
paddingLeft: MENU_ITEM_PAD,
|
|
371
455
|
paddingRight: MENU_ITEM_PAD,
|
|
372
|
-
cursor: dim ? undefined : 'pointer',
|
|
373
456
|
// A pill inside the sheet: the row is inset from the popup edge by
|
|
374
457
|
// the list's padding, and rounded so that its corner and the sheet's
|
|
375
458
|
// share a centre — the two curves are then one shape rather than two
|
|
@@ -397,21 +480,37 @@ function MenuRow({
|
|
|
397
480
|
: { ':active': { backgroundColor: theme.accentActive } }),
|
|
398
481
|
},
|
|
399
482
|
},
|
|
483
|
+
gutter > 0 &&
|
|
484
|
+
h(
|
|
485
|
+
'box',
|
|
486
|
+
{
|
|
487
|
+
// The mark sits at the head of the column and the gap is what
|
|
488
|
+
// follows it, rather than the column centring its box and leaving
|
|
489
|
+
// half the gap on either side: the label is the thing that has to
|
|
490
|
+
// stand clear, and the row's own padding already spaces the mark.
|
|
491
|
+
style: { width: gutter, alignItems: 'flex-start' },
|
|
492
|
+
},
|
|
493
|
+
gutterMark(item, { color: rowInk, fontSize }),
|
|
494
|
+
),
|
|
400
495
|
h(
|
|
401
|
-
'
|
|
402
|
-
{ style: {
|
|
403
|
-
|
|
496
|
+
'text',
|
|
497
|
+
{ style: [capTrim, { fontSize, fontWeight: MENU_TEXT_WEIGHT }] },
|
|
498
|
+
item.label,
|
|
404
499
|
),
|
|
405
|
-
h('text', { style: [capTrim, { fontSize }] }, item.label),
|
|
406
500
|
h('box', { style: { flexGrow: 1 } }),
|
|
407
|
-
// The accelerator
|
|
408
|
-
//
|
|
409
|
-
//
|
|
501
|
+
// The accelerator is quieter than the label at rest and rises with the
|
|
502
|
+
// row when it is chosen — so on an active row it says nothing and takes
|
|
503
|
+
// what the row set. A shortcut is a second way to do what the label
|
|
504
|
+
// already says; the chevron below it is not, and keeps the row's ink.
|
|
410
505
|
accelerator &&
|
|
411
506
|
h(
|
|
412
507
|
'text',
|
|
413
508
|
{
|
|
414
|
-
style: [
|
|
509
|
+
style: [
|
|
510
|
+
capTrim,
|
|
511
|
+
{ fontSize, fontWeight: MENU_TEXT_WEIGHT },
|
|
512
|
+
!active && { color: theme.textMuted },
|
|
513
|
+
],
|
|
415
514
|
},
|
|
416
515
|
accelerator,
|
|
417
516
|
),
|
|
@@ -424,7 +523,9 @@ function MenuRow({
|
|
|
424
523
|
// stands as tall as its box, so `MENU_ICON_SIZE` would put an arrow
|
|
425
524
|
// beside the label taller than the label.
|
|
426
525
|
size: capBand(fontSize),
|
|
427
|
-
|
|
526
|
+
// no `color`: the arrow is the row saying it has more behind it, as
|
|
527
|
+
// much a part of the entry as its label, and a muted one reads as a
|
|
528
|
+
// row that is half disabled rather than one with a submenu
|
|
428
529
|
}),
|
|
429
530
|
);
|
|
430
531
|
}
|
|
@@ -463,6 +564,9 @@ function MenuLevel({
|
|
|
463
564
|
}) {
|
|
464
565
|
const theme = useTheme();
|
|
465
566
|
const items = levelItems(rootItems, path, depth);
|
|
567
|
+
// one answer for the level, so every row indents by the same amount the
|
|
568
|
+
// popup was measured with
|
|
569
|
+
const gutter = menuGutter(items);
|
|
466
570
|
const active = path[depth] ?? -1;
|
|
467
571
|
const childItems = visibleItems(items[active]?.items);
|
|
468
572
|
const childOpen = path.length > depth + 1 && childItems.length > 0;
|
|
@@ -621,6 +725,7 @@ function MenuLevel({
|
|
|
621
725
|
item,
|
|
622
726
|
state: rowState(index, active, handedOn),
|
|
623
727
|
fontSize,
|
|
728
|
+
gutter,
|
|
624
729
|
nodeRef: index === active ? activeRowRef : undefined,
|
|
625
730
|
onHover: (ev) => hover(index, ev),
|
|
626
731
|
onMove: (ev) => move(index, ev),
|
|
@@ -902,6 +1007,11 @@ export function MenuBar({
|
|
|
902
1007
|
const theme = useTheme();
|
|
903
1008
|
const rtl = useDirection() === 'rtl';
|
|
904
1009
|
const [openIndex, setOpenIndex] = useState(-1);
|
|
1010
|
+
// The pointer's title, tracked here rather than left to a `:hover` block,
|
|
1011
|
+
// because the thing that lights up is no longer the node the pointer is
|
|
1012
|
+
// over: the pill is a child that reaches past its own item, and a state
|
|
1013
|
+
// block only ever answers for the node it is written on.
|
|
1014
|
+
const [hoverIndex, setHoverIndex] = useState(-1);
|
|
905
1015
|
const [rect, setRect] = useState(null);
|
|
906
1016
|
const [path, setPath] = useState([]);
|
|
907
1017
|
const refs = useRef([]);
|
|
@@ -1120,8 +1230,8 @@ export function MenuBar({
|
|
|
1120
1230
|
flexDirection: 'row',
|
|
1121
1231
|
alignItems: 'center',
|
|
1122
1232
|
backgroundColor: theme.surfaceHover,
|
|
1123
|
-
paddingLeft:
|
|
1124
|
-
paddingRight:
|
|
1233
|
+
paddingLeft: BAR_END_PAD,
|
|
1234
|
+
paddingRight: BAR_END_PAD,
|
|
1125
1235
|
// `scroll` is what makes a box report its viewport, and the clip
|
|
1126
1236
|
// that comes with it is wanted in its own right: it is what holds
|
|
1127
1237
|
// the one frame before the first measurement — and any bar whose
|
|
@@ -1172,10 +1282,17 @@ export function MenuBar({
|
|
|
1172
1282
|
onMouseDown: () =>
|
|
1173
1283
|
openIndex === index ? close() : openMenu(index, 'pointer'),
|
|
1174
1284
|
onMouseEnter: () => {
|
|
1285
|
+
setHoverIndex(index);
|
|
1175
1286
|
if (openIndex >= 0 && openIndex !== index) {
|
|
1176
1287
|
openMenu(index, 'pointer');
|
|
1177
1288
|
}
|
|
1178
1289
|
},
|
|
1290
|
+
onMouseLeave: () => {
|
|
1291
|
+
// the index rather than -1 unconditionally: the pointer reaches
|
|
1292
|
+
// the next title before this one hears it left, and clearing
|
|
1293
|
+
// then would put the bar back to nothing lit for a frame
|
|
1294
|
+
setHoverIndex((current) => (current === index ? -1 : current));
|
|
1295
|
+
},
|
|
1179
1296
|
// read live: by the time a hand-off blurs this item, the item
|
|
1180
1297
|
// taking over has already claimed the bar
|
|
1181
1298
|
onBlur: () => {
|
|
@@ -1226,33 +1343,14 @@ export function MenuBar({
|
|
|
1226
1343
|
},
|
|
1227
1344
|
style: [
|
|
1228
1345
|
{
|
|
1229
|
-
cursor: 'pointer',
|
|
1230
1346
|
paddingLeft: BAR_ITEM_PAD_X,
|
|
1231
1347
|
paddingRight: BAR_ITEM_PAD_X,
|
|
1232
|
-
//
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1237
|
-
// The margin is what a radius needs to be seen — a rounded
|
|
1238
|
-
// rect flush against the strip's own edges reads as a cut
|
|
1239
|
-
// corner rather than a pill. It sits *outside* the padding, so
|
|
1240
|
-
// the bar is a menu row plus its two insets tall: the item and
|
|
1241
|
-
// the row below it are then the same pill in the same size,
|
|
1242
|
-
// which is the whole point of giving the bar one.
|
|
1243
|
-
marginTop: BAR_INSET,
|
|
1244
|
-
marginBottom: BAR_INSET,
|
|
1245
|
-
marginLeft: BAR_GAP,
|
|
1246
|
-
marginRight: BAR_GAP,
|
|
1348
|
+
// No vertical margin: the pill fills the strip, which is a
|
|
1349
|
+
// menu row tall and nothing more. The item and the first row
|
|
1350
|
+
// of the menu it opens are then the same pill in the same
|
|
1351
|
+
// size, which is the whole point of giving the bar one.
|
|
1247
1352
|
paddingTop: MENU_ITEM_PAD,
|
|
1248
1353
|
paddingBottom: MENU_ITEM_PAD,
|
|
1249
|
-
borderRadius: rowRadius(theme, MENU_BORDER, MENU_PAD),
|
|
1250
|
-
backgroundColor:
|
|
1251
|
-
barState === 'active'
|
|
1252
|
-
? theme.hoverBackground
|
|
1253
|
-
: barState === 'path'
|
|
1254
|
-
? theme.surfaceActive
|
|
1255
|
-
: undefined,
|
|
1256
1354
|
// No ring while this item's menu is up. Walking the bar with
|
|
1257
1355
|
// the arrow keys opens each menu as it arrives, so the item is
|
|
1258
1356
|
// already inverted with a menu hanging off it — a ring on top
|
|
@@ -1267,16 +1365,40 @@ export function MenuBar({
|
|
|
1267
1365
|
// said once for whichever of the two the title turns out to be
|
|
1268
1366
|
color: barState === 'active' ? theme.hoverText : theme.text,
|
|
1269
1367
|
},
|
|
1270
|
-
// Only while this menu is shut, as in `Select`: an open one is
|
|
1271
|
-
// already showing the answer, and a state block would outrank
|
|
1272
|
-
// the base colour that says so. The menu opens on the release,
|
|
1273
|
-
// so `:active` is the whole of the answer to a held press.
|
|
1274
|
-
openIndex !== index && {
|
|
1275
|
-
':hover': { backgroundColor: theme.surface },
|
|
1276
|
-
':active': { backgroundColor: theme.surfaceActive },
|
|
1277
|
-
},
|
|
1278
1368
|
],
|
|
1279
1369
|
},
|
|
1370
|
+
// The pill, drawn before the title so the title is drawn on it. It
|
|
1371
|
+
// is a box of its own rather than this item's background because it
|
|
1372
|
+
// is wider than this item: `BAR_PILL_BLEED` past both edges, which
|
|
1373
|
+
// is where a native bar's highlight ends (see the constant). Only
|
|
1374
|
+
// while it has a colour to be — nothing at rest, since the strip
|
|
1375
|
+
// under it is already that colour.
|
|
1376
|
+
//
|
|
1377
|
+
// Hover is the state that used to be a `:hover` block here and is
|
|
1378
|
+
// now `hoverIndex`, and only while this menu is shut: an open one is
|
|
1379
|
+
// already showing the answer, and the hover colour would outrank the
|
|
1380
|
+
// base colour that says so.
|
|
1381
|
+
(barState || hoverIndex === index) &&
|
|
1382
|
+
h('box', {
|
|
1383
|
+
style: {
|
|
1384
|
+
position: 'absolute',
|
|
1385
|
+
left: -BAR_PILL_BLEED,
|
|
1386
|
+
right: -BAR_PILL_BLEED,
|
|
1387
|
+
top: 0,
|
|
1388
|
+
bottom: 0,
|
|
1389
|
+
// the same radius the rows inside the menu wear: the bar item
|
|
1390
|
+
// and the first row of the menu it opens are one gesture, and
|
|
1391
|
+
// a square title over rounded rows reads as two widgets that
|
|
1392
|
+
// have not met
|
|
1393
|
+
borderRadius: rowRadius(theme, MENU_BORDER, MENU_PAD),
|
|
1394
|
+
backgroundColor:
|
|
1395
|
+
barState === 'active'
|
|
1396
|
+
? theme.hoverBackground
|
|
1397
|
+
: barState === 'path'
|
|
1398
|
+
? theme.surfaceActive
|
|
1399
|
+
: theme.surface,
|
|
1400
|
+
},
|
|
1401
|
+
}),
|
|
1280
1402
|
overflow
|
|
1281
1403
|
? h(Icon, {
|
|
1282
1404
|
// The set's own overflow mark, rather than the `»` Qt and
|
|
@@ -1288,7 +1410,11 @@ export function MenuBar({
|
|
|
1288
1410
|
name: 'moreVertical',
|
|
1289
1411
|
size: iconSize(fontSize),
|
|
1290
1412
|
})
|
|
1291
|
-
: h(
|
|
1413
|
+
: h(
|
|
1414
|
+
'text',
|
|
1415
|
+
{ style: [capTrim, { fontSize, fontWeight: MENU_TEXT_WEIGHT }] },
|
|
1416
|
+
menu.label,
|
|
1417
|
+
),
|
|
1292
1418
|
);
|
|
1293
1419
|
}),
|
|
1294
1420
|
openIndex >= 0 &&
|
package/src/components/Select.js
CHANGED
|
@@ -131,7 +131,6 @@ function Option({
|
|
|
131
131
|
justifyContent: 'center',
|
|
132
132
|
paddingLeft: ITEM_PAD_LEFT,
|
|
133
133
|
paddingRight: ITEM_PAD_RIGHT,
|
|
134
|
-
cursor: 'pointer',
|
|
135
134
|
// the menus' pill, for the same reason and by the same rule: an
|
|
136
135
|
// option list and a menu are one surface with rows in it, and two
|
|
137
136
|
// shapes for that would only say the widgets were written apart
|
package/src/components/anchor.js
CHANGED
|
@@ -165,18 +165,27 @@ export function measureLabel(node, text, style) {
|
|
|
165
165
|
// exactly the kind of error that turns into an ellipsis on the longest
|
|
166
166
|
// label in the menu (src/scale.js).
|
|
167
167
|
const s = node?.scale ?? 1;
|
|
168
|
+
// The face **this node inherits**, not the literal `sans-serif` at whatever
|
|
169
|
+
// the defaults are: the labels these popups are sized around name no type
|
|
170
|
+
// of their own, so the one they are drawn in is the one that cascades down
|
|
171
|
+
// to them. Measuring in a different face is measuring the wrong label — a
|
|
172
|
+
// menu sized in sans-serif for a row that paints in a wider mono is a menu
|
|
173
|
+
// whose own options wrap.
|
|
174
|
+
//
|
|
175
|
+
// Which is every property that changes a glyph's advance, not the family
|
|
176
|
+
// alone. `fontVariationSettings` is the one that bites: a tree that sets
|
|
177
|
+
// `{ opsz: 17 }` on its window — the text cut of a variable face, wider
|
|
178
|
+
// than the display cut most files default to — draws every row in it and
|
|
179
|
+
// used to measure none, and the popup came out narrow enough to wrap its
|
|
180
|
+
// own labels. An explicit `style` still wins, since a caller that names a
|
|
181
|
+
// face is measuring something it is about to draw in that face.
|
|
182
|
+
const inherited = node?.inheritedTextStyle;
|
|
168
183
|
const layout = fonts.layout(String(text), {
|
|
169
|
-
|
|
170
|
-
// labels these popups are sized around name no family of their own, so
|
|
171
|
-
// the one they are drawn in is the palette's. Measuring in a different
|
|
172
|
-
// face is measuring the wrong label — a menu sized in sans-serif for a
|
|
173
|
-
// row that paints in a wider mono is a menu whose own options wrap.
|
|
174
|
-
family: style?.family ?? node?.inheritedTextStyle?.family ?? 'sans-serif',
|
|
184
|
+
family: style?.family ?? inherited?.family ?? 'sans-serif',
|
|
175
185
|
size: size * s,
|
|
176
|
-
weight: style?.weight ?? 'normal',
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
variations: style?.variations,
|
|
186
|
+
weight: style?.weight ?? inherited?.weight ?? 'normal',
|
|
187
|
+
style: style?.style ?? inherited?.style,
|
|
188
|
+
variations: style?.variations ?? inherited?.variations,
|
|
180
189
|
});
|
|
181
190
|
return { width: layout.width / s, height: layout.height / s };
|
|
182
191
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Whether this process talks to the desktop at all — the `desktop` option on
|
|
2
|
+
// `createRoot()`, and the one place that decides what it covers.
|
|
3
|
+
//
|
|
4
|
+
// ## What is in the group and why
|
|
5
|
+
//
|
|
6
|
+
// Three things react-x11 turns on for you without being asked, and all three
|
|
7
|
+
// reach the session bus:
|
|
8
|
+
//
|
|
9
|
+
// `appearance` the light/dark, accent, contrast and reduced-motion ladder
|
|
10
|
+
// (src/appearance.js) — the settings portal, then macOS, then
|
|
11
|
+
// XSETTINGS
|
|
12
|
+
// `a11y` the AT-SPI bridge, started from `createRoot()`
|
|
13
|
+
// (src/a11y.js, docs/accessibility.md)
|
|
14
|
+
// `globalMenu` a `MenuBar` handing its menu to the panel instead of drawing
|
|
15
|
+
// it (src/globalmenu.js, docs/globalmenu.md)
|
|
16
|
+
//
|
|
17
|
+
// Each already had an off switch and each of those was an **environment
|
|
18
|
+
// variable** — `REACT_X11_A11Y=0`, `NO_AT_BRIDGE=1`,
|
|
19
|
+
// `REACT_X11_NO_GLOBAL_MENU=1`, or unsetting `DBUS_SESSION_BUS_ADDRESS` for
|
|
20
|
+
// the appearance ladder. That is a seam an app cannot reach for itself: the
|
|
21
|
+
// environment is inherited, so a process that sets one before `createRoot()`
|
|
22
|
+
// has also set it for every child it spawns, and the D-Bus one turns off the
|
|
23
|
+
// portals and the app's own services along with the follower. AGENTS.md asks
|
|
24
|
+
// for the off switch to be somewhere the embedder can actually stand.
|
|
25
|
+
//
|
|
26
|
+
// ## Why the policy is process-wide when the option is per-root
|
|
27
|
+
//
|
|
28
|
+
// Because so is the thing it describes. There is one desktop, one D-Bus
|
|
29
|
+
// identity, and one AT-SPI bridge per process — `startA11y()` says so in its
|
|
30
|
+
// name, and appearance.js says so in its header. A per-root flag over
|
|
31
|
+
// process-wide state would be a seam that reads as finer than it is.
|
|
32
|
+
//
|
|
33
|
+
// So **off wins, and off latches.** A root that says `desktop: false` is a
|
|
34
|
+
// root with a constraint — an embedder that owns the toplevel, a test, a
|
|
35
|
+
// daemon that must not fork — and a second root quietly turning the feature
|
|
36
|
+
// back on for it would be the bug. Turning something back *on* is a thing
|
|
37
|
+
// this module deliberately cannot do.
|
|
38
|
+
//
|
|
39
|
+
// The one honest limit: a feature already started stays started. `startA11y()`
|
|
40
|
+
// is memoised per process, so a second root's `desktop: false` stops the next
|
|
41
|
+
// climb and not the bridge that is already up. Pass it on the first root.
|
|
42
|
+
|
|
43
|
+
/** @typedef {'appearance'|'a11y'|'globalMenu'} DesktopFeature */
|
|
44
|
+
|
|
45
|
+
/** @type {DesktopFeature[]} */
|
|
46
|
+
const FEATURES = ['appearance', 'a11y', 'globalMenu'];
|
|
47
|
+
|
|
48
|
+
/** What has been turned off, for the life of the process. */
|
|
49
|
+
const off = new Set();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Apply a root's `desktop` option. Not public — `createRoot({ desktop })` is
|
|
53
|
+
* the public shape.
|
|
54
|
+
*
|
|
55
|
+
* `undefined` is the default and means every feature stays on; `false` turns
|
|
56
|
+
* all of them off; an object names them one at a time, and a key left out is
|
|
57
|
+
* left alone.
|
|
58
|
+
*
|
|
59
|
+
* @param {boolean | Partial<Record<DesktopFeature, boolean>> | undefined} desktop
|
|
60
|
+
*/
|
|
61
|
+
export function setDesktopIntegration(desktop) {
|
|
62
|
+
if (desktop === undefined || desktop === true) return;
|
|
63
|
+
if (desktop === false) {
|
|
64
|
+
for (const feature of FEATURES) off.add(feature);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (typeof desktop !== 'object') {
|
|
68
|
+
throw new TypeError(
|
|
69
|
+
'react-x11: createRoot({ desktop }) takes false or an object of ' +
|
|
70
|
+
`${FEATURES.map((f) => `${f}: false`).join(', ')} — got ` +
|
|
71
|
+
`${JSON.stringify(desktop)}.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
for (const [feature, on] of Object.entries(desktop)) {
|
|
75
|
+
if (!FEATURES.includes(/** @type {DesktopFeature} */ (feature))) {
|
|
76
|
+
throw new TypeError(
|
|
77
|
+
`react-x11: createRoot({ desktop: { ${feature} } }) — no such ` +
|
|
78
|
+
`desktop integration. Expected ${FEATURES.join(', ')}.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (on === false) off.add(feature);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether a feature may run. Every one of the three asks this first, ahead of
|
|
87
|
+
* its own environment variable, so `desktop: false` is the outermost answer.
|
|
88
|
+
*
|
|
89
|
+
* @param {DesktopFeature} feature
|
|
90
|
+
*/
|
|
91
|
+
export function desktopIntegrationEnabled(feature) {
|
|
92
|
+
return !off.has(feature);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Test seam, not public: forget the latch. */
|
|
96
|
+
export function _resetDesktopIntegration() {
|
|
97
|
+
off.clear();
|
|
98
|
+
}
|
package/src/globalmenu.js
CHANGED
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
import { useEffect, useRef, useState } from 'react';
|
|
58
58
|
|
|
59
59
|
import { loadTransport, sessionBus } from './bus.js';
|
|
60
|
+
import { desktopIntegrationEnabled } from './desktopintegration.js';
|
|
60
61
|
import {
|
|
61
62
|
DBUSMENU_IFACE,
|
|
62
63
|
PROPERTY_TYPES,
|
|
@@ -126,6 +127,10 @@ const menuPathFor = (xid) => `/com/react_x11/menus/${xid}`;
|
|
|
126
127
|
* it is the one that works without touching application code.
|
|
127
128
|
*/
|
|
128
129
|
function globalMenuEnabled() {
|
|
130
|
+
// `createRoot({ desktop: false })` first: handing the menu to a panel is
|
|
131
|
+
// one of the three things core turns on for you, and that is the switch
|
|
132
|
+
// that turns the group off (src/desktopintegration.js, #417).
|
|
133
|
+
if (!desktopIntegrationEnabled('globalMenu')) return false;
|
|
129
134
|
const flag = process.env.REACT_X11_NO_GLOBAL_MENU;
|
|
130
135
|
return !flag || flag === '0';
|
|
131
136
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -298,6 +298,43 @@ export interface RootOptions {
|
|
|
298
298
|
* and coming back is the user's own Tab.
|
|
299
299
|
*/
|
|
300
300
|
restoreFocusOnReveal?: boolean;
|
|
301
|
+
/**
|
|
302
|
+
* Whether this process talks to the desktop over D-Bus. On by default, and
|
|
303
|
+
* `false` turns off all three things react-x11 starts for you (#417,
|
|
304
|
+
* docs/desktop.md):
|
|
305
|
+
*
|
|
306
|
+
* | | |
|
|
307
|
+
* | --- | --- |
|
|
308
|
+
* | `appearance` | following the desktop's light/dark, accent, contrast and reduced motion (docs/appearance.md) |
|
|
309
|
+
* | `a11y` | the AT-SPI bridge that makes the app reachable by a screen reader (docs/accessibility.md) |
|
|
310
|
+
* | `globalMenu` | a `MenuBar` handing its menu to the panel instead of drawing it (docs/globalmenu.md) |
|
|
311
|
+
*
|
|
312
|
+
* ```js
|
|
313
|
+
* await createRoot({ desktop: false }); // none of it
|
|
314
|
+
* await createRoot({ desktop: { appearance: false } }); // just that one
|
|
315
|
+
* ```
|
|
316
|
+
*
|
|
317
|
+
* For an embedder that owns those integrations itself, a kiosk or daemon
|
|
318
|
+
* that must not fork a subprocess to find the bus, and a test that wants
|
|
319
|
+
* one answer on every machine. With all three off nothing dials the
|
|
320
|
+
* session bus at startup.
|
|
321
|
+
*
|
|
322
|
+
* **Off is process-wide and it latches.** There is one desktop and one
|
|
323
|
+
* D-Bus identity per process, so a second root cannot turn back on what
|
|
324
|
+
* another turned off — and a feature already started stays started, so
|
|
325
|
+
* pass this on the first root.
|
|
326
|
+
*/
|
|
327
|
+
desktop?: boolean | DesktopIntegrationOptions;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Which desktop integrations run. See {@link CreateRootOptions.desktop}. */
|
|
331
|
+
export interface DesktopIntegrationOptions {
|
|
332
|
+
/** Follow the desktop's light/dark, accent, contrast and reduced motion. */
|
|
333
|
+
appearance?: boolean;
|
|
334
|
+
/** The AT-SPI bridge — whether a screen reader can see this app. */
|
|
335
|
+
a11y?: boolean;
|
|
336
|
+
/** Whether a `MenuBar` hands its menu to the panel. */
|
|
337
|
+
globalMenu?: boolean;
|
|
301
338
|
}
|
|
302
339
|
|
|
303
340
|
export interface ComposeOptions {
|
package/src/nodes.js
CHANGED
|
@@ -1218,6 +1218,19 @@ function contentSpan(node, axis, intrinsic, out) {
|
|
|
1218
1218
|
* one pass by `contentSpan`. `floored` collects what was written so the next
|
|
1219
1219
|
* measurement can take it back off — a floor left in place would be read
|
|
1220
1220
|
* back as content that cannot give, and could then only ratchet upwards.
|
|
1221
|
+
*
|
|
1222
|
+
* A floor is written **unrounded**, and the measurement it came from ran
|
|
1223
|
+
* with the pixel grid off (`measuringExactly`) for the reason given there:
|
|
1224
|
+
* rounding a floor grows the tree a pixel per nesting level. What that
|
|
1225
|
+
* leaves is a sharp edge in yoga worth knowing about before writing a
|
|
1226
|
+
* measure function. A line whose items are all held at their floors is one
|
|
1227
|
+
* yoga freezes item by item, subtracting each item's shrink factor from the
|
|
1228
|
+
* line's total as it goes; the total only cancels to zero if the sizes add
|
|
1229
|
+
* up exactly in binary. Three items of, say, 239.28 in a column that
|
|
1230
|
+
* overflows do not, and yoga divides the overflow by the rounding residue
|
|
1231
|
+
* instead of skipping the division — the items come back a billion pixels
|
|
1232
|
+
* tall (issue #411). Whole pixels cancel exactly, which is why the text
|
|
1233
|
+
* measures here answer in them (`TextNode._trim`).
|
|
1221
1234
|
*/
|
|
1222
1235
|
function writeContentFloors(node, axis, mins, floored) {
|
|
1223
1236
|
const axisIsMain = mainAxisOf(node) === axis;
|
|
@@ -4587,7 +4600,12 @@ export class TextNode extends Node {
|
|
|
4587
4600
|
|
|
4588
4601
|
/** Height for a width: the paragraph shaped into whatever is on offer.
|
|
4589
4602
|
* The offer is `Infinity` when nothing bounds it, which is also what
|
|
4590
|
-
* `textWrap: 'nowrap'` asks for, so neither needs a mode.
|
|
4603
|
+
* `textWrap: 'nowrap'` asks for, so neither needs a mode.
|
|
4604
|
+
*
|
|
4605
|
+
* Both answers are **whole pixels**, the trimmed one included — see
|
|
4606
|
+
* `_trim` for why the rounding is not cosmetic. The glyphs are placed
|
|
4607
|
+
* from the unrounded trim (`_placedLayout`), so what the rounding moves
|
|
4608
|
+
* is the bottom edge of the box, by less than half a pixel. */
|
|
4591
4609
|
measureContent({ width }) {
|
|
4592
4610
|
const layout = this._layoutFor(this._wrapWidth(width));
|
|
4593
4611
|
if (!layout) return { width: 0, height: 0 };
|
|
@@ -4596,7 +4614,9 @@ export class TextNode extends Node {
|
|
|
4596
4614
|
width: Math.ceil(layout.width),
|
|
4597
4615
|
height: Math.max(
|
|
4598
4616
|
0,
|
|
4599
|
-
|
|
4617
|
+
trim
|
|
4618
|
+
? Math.round(Math.ceil(layout.height) - (trim.top + trim.bottom))
|
|
4619
|
+
: Math.ceil(layout.height),
|
|
4600
4620
|
),
|
|
4601
4621
|
};
|
|
4602
4622
|
}
|
|
@@ -4852,6 +4872,21 @@ export class TextNode extends Node {
|
|
|
4852
4872
|
* Measured in the coordinates the layout is **drawn** in, not the ones it
|
|
4853
4873
|
* reports: `halfLeading` shifts it, and deriving the baseline from the
|
|
4854
4874
|
* metrics again would silently disagree the day that shift changes.
|
|
4875
|
+
*
|
|
4876
|
+
* The amounts are fractions of a pixel and stay that way — the glyphs are
|
|
4877
|
+
* placed from them (`_placedLayout`). What must not stay fractional is the
|
|
4878
|
+
* **box** they leave behind, which is why `measureContent` rounds the
|
|
4879
|
+
* height it reports and this does not (issue #411).
|
|
4880
|
+
*
|
|
4881
|
+
* A trimmed label measures to the cap band, and a cap height is a fraction
|
|
4882
|
+
* of the em — so before the rounding, a column of trimmed titles handed
|
|
4883
|
+
* yoga three or four flex items whose main size had a fraction in it and
|
|
4884
|
+
* whose content floors (#249) were that same fraction. Yoga freezes a line
|
|
4885
|
+
* like that item by item and divides the overflow by a total shrink factor
|
|
4886
|
+
* that should have cancelled to zero; a fraction that is not exact in
|
|
4887
|
+
* binary leaves a rounding residue there instead, and dividing by it laid
|
|
4888
|
+
* the section titles of `examples/configurator` out 5.6 billion pixels
|
|
4889
|
+
* tall. See `writeContentFloors`, which is the other end of it.
|
|
4855
4890
|
*/
|
|
4856
4891
|
_trim(layout) {
|
|
4857
4892
|
if (this.style.textBoxTrim !== 'cap-alphabetic') return null;
|
|
@@ -6645,8 +6680,8 @@ export class TextInputNode extends Node {
|
|
|
6645
6680
|
/** A preferred width, capped to whatever is on offer — `Infinity` when
|
|
6646
6681
|
* nothing is, which is what makes the `Math.min` the whole rule. */
|
|
6647
6682
|
measureContent({ width }) {
|
|
6648
|
-
//
|
|
6649
|
-
//
|
|
6683
|
+
// `_capBand` rounds, and a trimmed `<text>` rounds the same band the
|
|
6684
|
+
// same way — rounding one of them and not the other is a pixel of
|
|
6650
6685
|
// difference between a field and the button beside it.
|
|
6651
6686
|
return { width: Math.min(150, width), height: this._capBand() };
|
|
6652
6687
|
}
|
|
@@ -6896,12 +6931,16 @@ export class TextInputNode extends Node {
|
|
|
6896
6931
|
};
|
|
6897
6932
|
}
|
|
6898
6933
|
|
|
6934
|
+
/** Whole pixels either way: a field's height is a flex item's main size,
|
|
6935
|
+
* and a fractional one costs the tree its content floors (see
|
|
6936
|
+
* `TextNode._trim`, issue #411). The face with no `capHeight` to round is
|
|
6937
|
+
* the one that reaches the fallback. */
|
|
6899
6938
|
_capBand() {
|
|
6900
6939
|
const style = this.resolvedTextStyle();
|
|
6901
6940
|
const cap = this.app?.fonts
|
|
6902
6941
|
?.match?.(style.family, { weight: style.weight, style: style.style })
|
|
6903
6942
|
?.metrics?.(style.size)?.capHeight;
|
|
6904
|
-
return
|
|
6943
|
+
return Math.round(cap || this._lineHeight());
|
|
6905
6944
|
}
|
|
6906
6945
|
|
|
6907
6946
|
/** Shaped layout of the current value, cached per (value, style,
|