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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.1.3",
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.4.0",
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 { addressFor, loadTransport } from './bus.js';
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
- const busAddress = addressFor('session');
1822
- if (!busAddress && process.platform !== 'darwin') return null;
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
- * Not public, but exported for atspi.js, whose one-shot discovery probe
139
- * dials its own short-lived connection rather than the shared one — see
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, which dbus-native
153
- // already falls back to on its own. Leave it undefined and let it.
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
- `react-x11: no ${kind} bus address. ` +
162
- (kind === 'session'
163
- ? '$DBUS_SESSION_BUS_ADDRESS is unset and $XDG_RUNTIME_DIR is not ' +
164
- 'set either, which is normal over ssh, under a bare startx and in ' +
165
- 'most containers.'
166
- : '$DBUS_SYSTEM_BUS_ADDRESS is unset and there is no default.'),
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
- const busAddress = addressFor(kind);
220
- if (!busAddress && process.platform !== 'darwin') {
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);
@@ -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
- const MENU_SEPARATOR_HEIGHT = 7;
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, and the bar that much
93
- // taller for it.
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
- // Horizontally it takes a little more. A row's label is not `MENU_ITEM_PAD`
96
- // from the pill's edge but a whole `MENU_GUTTER` in, past the check column,
97
- // so matching the row's padding here would read as the cramped one: on a
98
- // strip the pills sit shoulder to shoulder, with nothing but that padding
99
- // between one label and the next.
100
- const BAR_ITEM_PAD_X = MENU_ITEM_PAD + 4;
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
- const MENU_GUTTER = 24; // room for the check column
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, { size: fontSize }).width +
152
- MENU_SHORTCUT_GAP
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
- MENU_GUTTER +
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 ?? '', { size: fontSize }).width;
173
- return Math.ceil(text) + (BAR_ITEM_PAD_X + BAR_GAP) * 2;
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 - (BAR_INSET - BAR_GAP) * 2;
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
- 'box',
402
- { style: { width: MENU_GUTTER - MENU_ITEM_PAD, alignItems: 'center' } },
403
- gutterMark(item, { color: rowInk, fontSize }),
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 and the chevron are quieter than the label at rest and
408
- // rise with the row when it is chosen — so on an active row they say
409
- // nothing and take what the row set.
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: [capTrim, { fontSize }, !active && { color: theme.textMuted }],
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
- style: !active && { color: theme.textMuted },
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: BAR_INSET - BAR_GAP,
1124
- paddingRight: BAR_INSET - BAR_GAP,
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
- // The same pill the rows inside the menu wear, at the same
1233
- // radius: the bar item and the first row of the menu it opens
1234
- // are one gesture, and a square title over rounded rows reads
1235
- // as two widgets that have not met.
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('text', { style: [capTrim, { fontSize }] }, menu.label),
1413
+ : h(
1414
+ 'text',
1415
+ { style: [capTrim, { fontSize, fontWeight: MENU_TEXT_WEIGHT }] },
1416
+ menu.label,
1417
+ ),
1292
1418
  );
1293
1419
  }),
1294
1420
  openIndex >= 0 &&
@@ -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
@@ -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
- // The face **this node inherits**, not the literal `sans-serif`: the
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
- // dropping this would measure a different face from the one drawn, and
178
- // the popup sized here would be the wrong width for its own label
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
- Math.ceil(layout.height) - (trim ? trim.top + trim.bottom : 0),
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
- // Not rounded here: a trimmed `<text>` hands layout the raw cap band
6649
- // too, and rounding one of them and not the other is a pixel of
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 cap ? Math.round(cap) : this._lineHeight();
6943
+ return Math.round(cap || this._lineHeight());
6905
6944
  }
6906
6945
 
6907
6946
  /** Shaped layout of the current value, cached per (value, style,