react-x11 2.22.8 → 2.22.10

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.22.8",
3
+ "version": "2.22.10",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -0,0 +1,83 @@
1
+ // How often an accessibility bridge tells its platform what changed.
2
+ //
3
+ // Both bridges mirror the tree for a platform that reads it on its own
4
+ // schedule: AT-SPI (src/atspi.js) exports objects and sends signals, UI
5
+ // Automation (src/win32/a11y.js) walks a window and pushes a diff. Pushed on
6
+ // every commit, that is paid by everything that commits often, and mostly
7
+ // for nobody. On Windows it was 1.6 ms of each frame of a graph pane's pan
8
+ // (#656). On a Linux desktop whose session runs the AT-SPI bus, as most do
9
+ // with no screen reader in sight, it was a sixth of a 100,000-row table's
10
+ // fling: rows exported, diffed and unexported as they scrolled past.
11
+ //
12
+ // The rule is the one browsers use. Chromium's AXObjectCacheImpl batches
13
+ // non-interactive updates, 150 ms apart once a page has loaded and 350 ms
14
+ // before, and the bounds of anything that is not focused 500 ms apart. It
15
+ // serializes a focus or selection change at once, because that is what the
16
+ // user is waiting on. So here:
17
+ //
18
+ // - a change after a quiet spell is pushed at once, so a click or a key
19
+ // that changes one thing is heard immediately;
20
+ // - a stream of changes (a scroll, a pan, a value ticking) is pushed at
21
+ // most once per `interval`, and the last push carries the last state;
22
+ // - what the user is waiting on goes at once regardless: a focus change,
23
+ // an announcement, and a change to the focused element itself, such as
24
+ // the characters typed into it. That exception is each bridge's to
25
+ // call (`urgent`).
26
+ //
27
+ // The interval is 500 ms: at most two pushes a second, the slower end of
28
+ // what Chromium spends. `REACT_X11_A11Y_INTERVAL` sets it in milliseconds,
29
+ // and `0` pushes on every change, as both bridges once did.
30
+
31
+ export const DEFAULT_A11Y_INTERVAL = 500;
32
+
33
+ /**
34
+ * The push interval: `REACT_X11_A11Y_INTERVAL` in milliseconds where it is a
35
+ * number of them (`0` included), and the default otherwise.
36
+ */
37
+ export function a11yInterval(env = globalThis.process?.env ?? {}) {
38
+ const raw = env.REACT_X11_A11Y_INTERVAL;
39
+ if (raw === undefined || raw === '') return DEFAULT_A11Y_INTERVAL;
40
+ const ms = Number(raw);
41
+ return Number.isFinite(ms) && ms >= 0 ? ms : DEFAULT_A11Y_INTERVAL;
42
+ }
43
+
44
+ /**
45
+ * The pacing, without the pushing: the bridge asks whether a push may go now
46
+ * and says when one went, and the pacer keeps the one timer a stream owes.
47
+ */
48
+ export class A11yPacer {
49
+ constructor(interval = a11yInterval()) {
50
+ this.interval = interval;
51
+ this._last = -Infinity;
52
+ this._timer = null;
53
+ }
54
+
55
+ /**
56
+ * Something changed. True when it may be pushed now, because nothing has
57
+ * been pushed for an interval. False when `later` has been set to run once
58
+ * the interval is up: one timer, however many changes ask while it runs.
59
+ */
60
+ ready(later) {
61
+ if (this._timer) return false;
62
+ const wait = this._last + this.interval - performance.now();
63
+ if (wait <= 0) return true;
64
+ this._timer = setTimeout(() => {
65
+ this._timer = null;
66
+ later();
67
+ }, wait);
68
+ this._timer.unref?.();
69
+ return false;
70
+ }
71
+
72
+ /** A push went out, paced or not: the next interval runs from here. */
73
+ pushed() {
74
+ this._last = performance.now();
75
+ }
76
+
77
+ /** Drop the push a stream is owed, for one that goes now and takes it. */
78
+ cancel() {
79
+ if (this._timer === null) return;
80
+ clearTimeout(this._timer);
81
+ this._timer = null;
82
+ }
83
+ }
package/src/atspi.js CHANGED
@@ -62,6 +62,7 @@ import {
62
62
  diffChars,
63
63
  } from './a11y.js';
64
64
  import { onApp } from './trace-registry.js';
65
+ import { A11yPacer } from './a11ypace.js';
65
66
  import { synthesizeClick } from './events.js';
66
67
  import { callHandler } from './errors.js';
67
68
 
@@ -100,6 +101,8 @@ const LAYER_WINDOW = 7;
100
101
  const ATSPI_VERSION = '2.1';
101
102
 
102
103
  const EVENT_SIGNATURE = 'siiva{sv}';
104
+ /** D-Bus message type 1: a method call, which is how an AT reads. */
105
+ const METHOD_CALL = 1;
103
106
  const CACHE_ITEM_SIGNATURE = '((so)(so)(so)iiassusau)';
104
107
 
105
108
  // --------------------------------------------------------------------------
@@ -765,7 +768,8 @@ class AtspiBridge {
765
768
  this.desktop = [REGISTRY_NAME, ROOT_PATH];
766
769
  this.appId = -1;
767
770
  this._unsubscribes = [];
768
- // batched renderer notifications, flushed per commit / microtask
771
+ // batched renderer notifications, flushed in a microtask once the pacer
772
+ // says so (src/a11ypace.js), and at once for what the user waits on
769
773
  this._pending = {
770
774
  attach: new Set(),
771
775
  remove: [],
@@ -775,6 +779,17 @@ class AtspiBridge {
775
779
  window: [],
776
780
  };
777
781
  this._flushScheduled = false;
782
+ this._pacer = new A11yPacer();
783
+ /** The focused node, whose own changes are not paced. */
784
+ this._focused = null;
785
+ // Pushes are paced, reads are not: whatever the pacer is holding is
786
+ // flushed before dbus-native dispatches an AT's call, so the objects and
787
+ // interfaces it asks about exist as the tree has them now. A node React
788
+ // reused as a slider still exported a button's interfaces until the
789
+ // next push, and a Value read of it failed.
790
+ bus.connection?.prependListener?.('message', (msg) => {
791
+ if (msg.type === METHOD_CALL && this._hasPending()) this.flush();
792
+ });
778
793
  }
779
794
 
780
795
  // ---- refs and export -------------------------------------------------
@@ -1184,12 +1199,35 @@ class AtspiBridge {
1184
1199
 
1185
1200
  // ---- the queue -------------------------------------------------------
1186
1201
 
1202
+ /**
1203
+ * A change to tell the bus about, when the pacer allows: in this turn's
1204
+ * microtask after a quiet spell, and otherwise once its interval is up,
1205
+ * together with everything else that changed meanwhile. Rows that scroll
1206
+ * in and out between two pushes are then never exported at all: the attach
1207
+ * finds them gone, and the removal finds nothing the AT ever saw.
1208
+ */
1187
1209
  _schedule() {
1210
+ if (this._flushScheduled || this.dead) return;
1211
+ if (this._pacer.ready(() => this.flush())) this._flushSoon();
1212
+ }
1213
+
1214
+ _flushSoon() {
1188
1215
  if (this._flushScheduled || this.dead) return;
1189
1216
  this._flushScheduled = true;
1190
1217
  queueMicrotask(() => this.flush());
1191
1218
  }
1192
1219
 
1220
+ /**
1221
+ * A change the user is waiting to hear: focus moving, or the focused
1222
+ * element's own name, state or text, such as the characters typed into
1223
+ * it. Chromium serializes these at once too. It takes everything pending
1224
+ * with it, so the AT can resolve whatever the event names.
1225
+ */
1226
+ _urgent() {
1227
+ this._pacer.cancel();
1228
+ this._flushSoon();
1229
+ }
1230
+
1193
1231
  /** Is this node under a toplevel the bridge is presenting? Detached
1194
1232
  * subtrees under construction are not, which is what keeps the initial
1195
1233
  * bottom-up build of a tree from queueing one event per node. */
@@ -1214,38 +1252,47 @@ class AtspiBridge {
1214
1252
 
1215
1253
  queueProps(node) {
1216
1254
  this._pending.props.add(node);
1217
- this._schedule();
1255
+ if (node === this._focused) this._urgent();
1256
+ else this._schedule();
1218
1257
  }
1219
1258
 
1220
1259
  queueText(node) {
1221
1260
  this._pending.text.add(node);
1222
- this._schedule();
1261
+ if (node === this._focused) this._urgent();
1262
+ else this._schedule();
1223
1263
  }
1224
1264
 
1225
1265
  queueFocus(previous, next) {
1226
1266
  this._pending.focus.push({ previous, next });
1227
- this._schedule();
1267
+ this._urgent();
1228
1268
  }
1229
1269
 
1230
1270
  queueWindowFocus(win, focused) {
1231
1271
  this._pending.window.push({ win, focused });
1232
- this._schedule();
1272
+ this._urgent();
1273
+ }
1274
+
1275
+ _hasPending() {
1276
+ const p = this._pending;
1277
+ return (
1278
+ p.attach.size > 0 ||
1279
+ p.remove.length > 0 ||
1280
+ p.props.size > 0 ||
1281
+ p.text.size > 0 ||
1282
+ p.focus.length > 0 ||
1283
+ p.window.length > 0
1284
+ );
1233
1285
  }
1234
1286
 
1235
1287
  flush() {
1236
1288
  if (this.dead) return;
1237
1289
  this._flushScheduled = false;
1238
1290
  const p = this._pending;
1239
- if (
1240
- p.attach.size === 0 &&
1241
- p.remove.length === 0 &&
1242
- p.props.size === 0 &&
1243
- p.text.size === 0 &&
1244
- p.focus.length === 0 &&
1245
- p.window.length === 0
1246
- ) {
1247
- return;
1248
- }
1291
+ if (!this._hasPending()) return;
1292
+ // this push takes what a stream was owed, and the next interval runs
1293
+ // from here
1294
+ this._pacer.cancel();
1295
+ this._pacer.pushed();
1249
1296
  this._pending = {
1250
1297
  attach: new Set(),
1251
1298
  remove: [],
@@ -1699,8 +1746,15 @@ class AtspiBridge {
1699
1746
  this.queueAttach(child);
1700
1747
  };
1701
1748
  hooks.detach = (parent, child) => {
1702
- if (!this.exported.has(child) && !this._live(parent)) return;
1703
- this.queueRemove(parent, child, a11yIndexIn(parent, child));
1749
+ const exported = this.exported.has(child);
1750
+ if (!exported && !this._live(parent)) return;
1751
+ // the index is for the AT's children-changed, which only a child it
1752
+ // saw is owed — and a row scrolled away was usually never exported
1753
+ this.queueRemove(
1754
+ parent,
1755
+ child,
1756
+ exported ? a11yIndexIn(parent, child) : -1,
1757
+ );
1704
1758
  };
1705
1759
  hooks.propsChanged = (node) => {
1706
1760
  if (this.exported.has(node)) this.queueProps(node);
@@ -1720,12 +1774,15 @@ class AtspiBridge {
1720
1774
  if (this.exported.has(node)) this.queueText(node);
1721
1775
  };
1722
1776
  hooks.focus = (previous, next) => {
1777
+ this._focused = next ?? null;
1723
1778
  this.queueFocus(previous, next);
1724
1779
  };
1725
1780
  hooks.windowFocus = (win, focused) => {
1726
1781
  this.queueWindowFocus(win, focused);
1727
1782
  };
1728
- hooks.commit = () => this.flush();
1783
+ // a commit is one more change to pace, not a push of its own: every
1784
+ // hook above has already asked for the one it needs
1785
+ hooks.commit = () => this._schedule();
1729
1786
  hooks.announce = (text, opts) => {
1730
1787
  // spoken from the active window, which is the context the user is in
1731
1788
  const target =
@@ -1758,6 +1815,7 @@ class AtspiBridge {
1758
1815
  bury() {
1759
1816
  if (this.dead) return;
1760
1817
  this.dead = true;
1818
+ this._pacer.cancel();
1761
1819
  for (const key of Object.keys(hooks)) hooks[key] = null;
1762
1820
  for (const unsubscribe of this._unsubscribes) unsubscribe();
1763
1821
  this._unsubscribes = [];
package/src/cocoa/app.js CHANGED
@@ -965,6 +965,7 @@ export class CocoaApp {
965
965
  // which is more than a display frame when the change costs layout
966
966
  // (issue #641). The Wayland backend orders them the same way
967
967
  // (`requestSurfaceFrame`).
968
+ const frames = [];
968
969
  const surfaces = [];
969
970
  for (const entry of queue) {
970
971
  // A window whose last flip has not reached its layer yet (threaded
@@ -993,10 +994,36 @@ export class CocoaApp {
993
994
  this._rafQueue.push(entry);
994
995
  continue;
995
996
  }
996
- if (entry.surface) surfaces.push(entry);
997
- else runFrame(entry, now);
997
+ (entry.surface ? surfaces : frames).push(entry);
998
998
  }
999
- for (const entry of surfaces) runFrame(entry, now);
999
+ // …and in one commit with them. The overlay and the GL frame are two
1000
+ // layers, each flipped in a transaction of its own — the overlay's in
1001
+ // the window's flush, the GL frame's after its draw — and the draw came
1002
+ // between the two commits: a frame that rebuilt its scene let the
1003
+ // overlay reach the screen a refresh or more before the frame it was
1004
+ // painted for. So a window with a surface drawing in this tick has its
1005
+ // frame and theirs inside one transaction, which puts every layer the
1006
+ // two changed on glass at once; committed with the window's size, as
1007
+ // the frame a worker's resize handshake waits for (`_flip`).
1008
+ const joined = new Set();
1009
+ for (const entry of surfaces) if (entry.wnd) joined.add(entry.wnd);
1010
+ for (const entry of frames) {
1011
+ if (!joined.has(entry.wnd)) runFrame(entry, now);
1012
+ }
1013
+ for (const wnd of joined) {
1014
+ this._native.txBegin({ disableActions: true });
1015
+ try {
1016
+ for (const entry of frames) {
1017
+ if (entry.wnd === wnd) runFrame(entry, now);
1018
+ }
1019
+ for (const entry of surfaces) {
1020
+ if (entry.wnd === wnd) runFrame(entry, now);
1021
+ }
1022
+ } finally {
1023
+ this._native.txCommit(wnd._frameSize?.());
1024
+ }
1025
+ }
1026
+ for (const entry of surfaces) if (!entry.wnd) runFrame(entry, now);
1000
1027
  this._armFrameTimer(soonest, now);
1001
1028
  }
1002
1029
 
@@ -31,8 +31,23 @@
31
31
  // caller draws what it had meanwhile. A bezel drawn at rest brings its
32
32
  // pressed twin along, so the press — the input whose answer is the bezel —
33
33
  // never waits for one (AGENTS.md, "Answer the input").
34
+ //
35
+ // ## Taller than one line
36
+ //
37
+ // A push bezel is one height. Asked to fill a taller frame, AppKit draws its
38
+ // own 22pt bezel centred in it — and a `<Button>` whose label wrapped is a
39
+ // taller frame. What AppKit has for that button is the *flexible* push,
40
+ // `NSBezelStyleFlexiblePush`: the same bezel stretched to its frame, the one
41
+ // AppKit gives a title that wraps, and what WebKit and Gecko draw a push
42
+ // button taller than a push button with. At one line the two are the same
43
+ // pixels, the default button's accent aside (the push has a sheen, the
44
+ // flexible one is flat), so a push box keeps its bezel at its own height and
45
+ // is drawn as the flexible push above it (`TALLER`). A bridge that predates
46
+ // the kind keeps the push (`_draws`).
34
47
 
35
48
  const KINDS = ['push', 'checkbox', 'radio', 'popup', 'slider', 'switch'];
49
+ // what a kind is drawn as in a box taller than its bezel
50
+ const TALLER = Object.freeze({ push: 'flexiblePush' });
36
51
  // the sizes the widget set lays its controls out at (components/native.js)
37
52
  const SIZES = ['regular', 'small'];
38
53
  // the fullest state, so the scan sees the whole footprint
@@ -55,6 +70,7 @@ export class BezelStore {
55
70
  this._scanning = new Map(); // kind|size|scale → the scan in flight
56
71
  this._cache = new Map(); // full param key → { surface, sx, sy, sw, sh }
57
72
  this._drawing = new Map(); // full param key → callbacks awaiting it
73
+ this._drawable = new Map(); // kind → whether the bridge draws it
58
74
  this._gen = 0;
59
75
  this._MAX = 160;
60
76
  }
@@ -118,9 +134,13 @@ export class BezelStore {
118
134
  * each scan running the first time it is asked for.
119
135
  */
120
136
  prefetch(scale) {
137
+ const kinds = [
138
+ ...KINDS,
139
+ ...Object.values(TALLER).filter((kind) => this._draws(kind)),
140
+ ];
121
141
  const jobs = [];
122
142
  for (const s of new Set([2, scale])) {
123
- for (const kind of KINDS) {
143
+ for (const kind of kinds) {
124
144
  for (const size of SIZES) jobs.push(this._scanLater(kind, size, s));
125
145
  }
126
146
  }
@@ -132,9 +152,13 @@ export class BezelStore {
132
152
  * Returns `{ surface, sx, sy, sw, sh }` — draw with the 9-arg
133
153
  * `ctx.drawImage` so the ink region lands exactly on the box. On a worker
134
154
  * a bezel not drawn yet is null, and `onReady` is called once it is in.
155
+ * A push box taller than a push bezel is drawn as the flexible push
156
+ * ("Taller than one line").
135
157
  */
136
158
  get(params, w, h, scale, onReady = null) {
137
159
  const controlSize = params.controlSize ?? 'regular';
160
+ const kind = this._kindAt(params.kind, controlSize, h / scale);
161
+ if (kind !== params.kind) params = { ...params, kind };
138
162
  const key = JSON.stringify([
139
163
  params.kind,
140
164
  controlSize,
@@ -170,6 +194,50 @@ export class BezelStore {
170
194
  return frame.entry;
171
195
  }
172
196
 
197
+ /** The kind that draws a `kind` box `height` logical px tall: itself, or
198
+ * above its bezel's own height the kind `TALLER` names for it, where the
199
+ * bridge draws that. On a worker, before `prefetch`, a kind whose height
200
+ * is not in yet is itself. */
201
+ _kindAt(kind, controlSize, height) {
202
+ const taller = TALLER[kind];
203
+ if (!taller) return kind;
204
+ const natural = this.natural(kind, controlSize);
205
+ if (!natural || height <= natural.height) return kind;
206
+ return this._draws(taller) ? taller : kind;
207
+ }
208
+
209
+ /**
210
+ * Whether the bridge draws `kind` at all. One that predates it throws
211
+ * "unknown control kind" as it reads the parameters — on either thread,
212
+ * before anything is measured or dispatched — so the question costs one
213
+ * measure, once. A no keeps the kind it would have replaced, which is a
214
+ * button whose bezel stops short of its label: broken rather than
215
+ * degraded, so development hears about it, once.
216
+ */
217
+ _draws(kind) {
218
+ let known = this._drawable.get(kind);
219
+ if (known !== undefined) return known;
220
+ try {
221
+ // off the main thread the callback is how the bridge answers at all;
222
+ // on it, the call answers in place
223
+ if (this._answersLater()) this._native.measureControl({ kind }, () => {});
224
+ else this._native.measureControl({ kind });
225
+ known = true;
226
+ } catch {
227
+ known = false;
228
+ if (process.env.NODE_ENV !== 'production') {
229
+ console.warn(
230
+ `react-x11: this @windowkit/appkit has no '${kind}' bezel, so a ` +
231
+ 'native <Button> whose label wrapped is drawn over a one-line ' +
232
+ 'bezel that stops short of it. Update @windowkit/appkit to the ' +
233
+ 'version react-x11 lists in its optionalDependencies.',
234
+ );
235
+ }
236
+ }
237
+ this._drawable.set(kind, known);
238
+ return known;
239
+ }
240
+
173
241
  /** A surface padded out by the kind's insets, and the region of it a box
174
242
  * `w` × `h` blits from. */
175
243
  _frame(c, w, h, scale) {
@@ -1013,6 +1013,30 @@ export class CocoaWindow {
1013
1013
  return this.app._requestFrame(cb, this, true);
1014
1014
  }
1015
1015
 
1016
+ /**
1017
+ * Whether a `<glarea>` of this window has a frame queued on its clock —
1018
+ * which keeps this window's frame on the clock too, where the early frame
1019
+ * an input asks for would have painted it at once (src/frames.js).
1020
+ *
1021
+ * The clock pairs the two: a tick runs this window's frame and then its
1022
+ * surfaces', so a GL frame goes out after the overlay its children were
1023
+ * painted on (#641). An input painted early went around that — the
1024
+ * window's frame, the overlay with it and the present, inside the input's
1025
+ * own dispatch, and the GL frame the same input asked for a refresh or
1026
+ * more later, on the clock. For that long the children were drawn over
1027
+ * the frame before: a graph whose cards were a surface's children hid
1028
+ * them for a wheel zoom that way, and showed its edges with nothing under
1029
+ * them for 20–25ms, until the GL frame that drew the cards came. Such an
1030
+ * input is answered on the clock, both halves in order. What it gives up
1031
+ * is its 2D half's head start; the GL half never had one.
1032
+ */
1033
+ surfaceFrameOwed() {
1034
+ for (const entry of this.app._rafQueue) {
1035
+ if (entry.surface && entry.wnd === this) return true;
1036
+ }
1037
+ return false;
1038
+ }
1039
+
1016
1040
  /**
1017
1041
  * The earliest moment this window's clock will hand out another frame,
1018
1042
  * for a gate of `interval` ms — the display's period by default, which
@@ -8,11 +8,11 @@ import {
8
8
  ABS_FILL,
9
9
  Bezel,
10
10
  NATIVE_BAND,
11
+ NATIVE_FOOTPRINT,
11
12
  NATIVE_RING,
12
- TITLE_BASELINE,
13
13
  bezelNatural,
14
14
  bezelShadow,
15
- nativeFootprintStyle,
15
+ nativeTitleMargins,
16
16
  nativeTitleStyle,
17
17
  pressWash,
18
18
  useNativeControls,
@@ -24,6 +24,11 @@ const h = React.createElement;
24
24
  const VARIANTS = ['solid', 'outline', 'ghost'];
25
25
  const SIZES = ['medium', 'small'];
26
26
 
27
+ // A label squeezed below its width wraps at its words, and the lines are
28
+ // centred under one another, as AppKit centres a title that wrapped and a
29
+ // browser a `<button>`'s. One line is as wide as its box, so it is not moved.
30
+ const CENTRED_LINES = Object.freeze({ textAlign: 'center' });
31
+
27
32
  /**
28
33
  * <Button onPress variant size primary disabled …boxProps>label</Button> —
29
34
  * the standard push button the examples kept re-implementing: hover/press/
@@ -97,8 +102,8 @@ export function Button({
97
102
  {
98
103
  theme,
99
104
  // The footprint the caller's style sizes; the button below keeps
100
- // AppKit's height inside it, centred (`nativeFootprintStyle`).
101
- style: [nativeFootprintStyle(nat), style],
105
+ // its own height inside it, centred (`NATIVE_FOOTPRINT`).
106
+ style: [NATIVE_FOOTPRINT, style],
102
107
  },
103
108
  h(
104
109
  'box',
@@ -117,16 +122,23 @@ export function Button({
117
122
  gap: small ? 6 : 8,
118
123
  // AppKit's metrics, not the palette's: a native bezel is
119
124
  // designed at its own height, and stretching it is what this
120
- // mode exists to avoid. Width still follows the label.
121
- height: nat.height,
125
+ // mode exists to avoid. Width still follows the label — and
126
+ // so does the height, once there is not the width for the
127
+ // label: it wraps at its words, as a drawn one does, and the
128
+ // lines it grew by make the control taller. AppKit has a
129
+ // bezel for that button (src/cocoa/bezels.js); a title
130
+ // pinned to one line's height put its first line above the
131
+ // bezel instead.
132
+ minHeight: nat.height,
122
133
  paddingLeft: small ? 10 : 14,
123
134
  paddingRight: small ? 10 : 14,
124
135
  // The natural box is the bezel's footprint, shadow included;
125
- // the body is what the title is placed against — and placed,
126
- // not centred (`TITLE_BASELINE`). A label centred by its
127
- // capitals sat 1pt low beside a native button.
136
+ // inside the shadow is the body, which an icon is centred on
137
+ // and the title is placed against — placed, not centred
138
+ // (`nativeTitleMargins`). A label centred by its capitals sat
139
+ // 1pt low beside a native button.
128
140
  paddingTop: shadow.top,
129
- paddingBottom: shadow.bottom + TITLE_BASELINE[controlSize],
141
+ paddingBottom: shadow.bottom,
130
142
  // The keyboard ring is the renderer's, on this box — shaped by
131
143
  // the bezel's corners and hugging it, as AppKit's is
132
144
  // (`NATIVE_RING`), rather than the palette's offset rectangle.
@@ -151,7 +163,11 @@ export function Button({
151
163
  isDefault: primary && !disabled,
152
164
  style: ABS_FILL,
153
165
  }),
154
- labelContent(children ?? label, nativeTitleStyle(controlSize)),
166
+ labelContent(children ?? label, [
167
+ nativeTitleStyle(controlSize),
168
+ nativeTitleMargins(nat, shadow, controlSize),
169
+ CENTRED_LINES,
170
+ ]),
155
171
  // The press answer. Last child on purpose: `:active` marks the
156
172
  // pressed node and its ancestors, and the topmost child is what the
157
173
  // press lands on. No hover tint — AppKit buttons have none.
@@ -271,6 +287,6 @@ export function Button({
271
287
  style,
272
288
  ],
273
289
  },
274
- labelContent(children ?? label),
290
+ labelContent(children ?? label, CENTRED_LINES),
275
291
  );
276
292
  }
@@ -10,12 +10,12 @@ import {
10
10
  ABS_FILL,
11
11
  Bezel,
12
12
  NATIVE_BAND,
13
+ NATIVE_FOOTPRINT,
13
14
  NATIVE_MENU,
14
15
  NATIVE_RING,
15
16
  TITLE_BASELINE,
16
17
  bezelNatural,
17
18
  bezelShadow,
18
- nativeFootprintStyle,
19
19
  nativeTitleStyle,
20
20
  pressWash,
21
21
  useNativeControls,
@@ -550,7 +550,7 @@ export function Select({
550
550
  },
551
551
  // The caller's style, on the trigger where the trigger is the whole
552
552
  // control. Under a native bezel it sizes the footprint below instead
553
- // and the trigger keeps AppKit's height (`nativeFootprintStyle`).
553
+ // and the trigger keeps AppKit's height (`NATIVE_FOOTPRINT`).
554
554
  !nativeControls && style,
555
555
  ],
556
556
  },
@@ -677,6 +677,6 @@ export function Select({
677
677
  // around them is what a `height`, a `flexGrow` or a parent's align-stretch
678
678
  // is free to make taller, with the control centred in it (issue #510).
679
679
  return nativeControls
680
- ? h('box', { theme, style: [nativeFootprintStyle(nat), style] }, trigger)
680
+ ? h('box', { theme, style: [NATIVE_FOOTPRINT, style] }, trigger)
681
681
  : trigger;
682
682
  }