react-x11 2.22.9 → 2.22.11

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.9",
3
+ "version": "2.22.11",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -108,7 +108,7 @@
108
108
  "yoga-layout": "^3.2.1"
109
109
  },
110
110
  "optionalDependencies": {
111
- "@windowkit/appkit": "^0.15.0",
111
+ "@windowkit/appkit": "^0.16.0",
112
112
  "@windowkit/wayland": "^3.1.1",
113
113
  "@windowkit/win32": "^0.0.5",
114
114
  "dbus-native": "^0.15.1",
@@ -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
  }
@@ -19,7 +19,7 @@
19
19
 
20
20
  import React, { useCallback, useRef } from 'react';
21
21
  import { useAppOrNull } from '../appcontext.js';
22
- import { useTheme } from './theme.js';
22
+ import { capBand, useTheme } from './theme.js';
23
23
 
24
24
  const h = React.createElement;
25
25
 
@@ -101,6 +101,37 @@ export function nativeTitleStyle(controlSize = 'regular') {
101
101
  return { alignSelf: 'flex-end', fontSize: CONTROL_FONT_SIZE[controlSize] };
102
102
  }
103
103
 
104
+ /**
105
+ * Where a push button's title goes in a control whose padding is only the
106
+ * bezel's shadow — so the box inside it is the body, and an icon beside the
107
+ * title is centred on the body as AppKit centres an image. The title is a
108
+ * margin box inside that: `TITLE_BASELINE` under its last baseline, and over
109
+ * its capitals the room a one-line title leaves there in a body of the
110
+ * bezel's own height. So a title that fits sits exactly where the cell puts
111
+ * it, filling the body to the pixel, and one that wrapped keeps the same
112
+ * margins round the lines it grew by: the control is taller by those lines,
113
+ * and so is the bezel drawn to it (src/cocoa/bezels.js, the flexible push).
114
+ * AppKit's own flexible push starts a wrapped title's capitals within half a
115
+ * point of here (4.5pt under the body's top edge at the regular size, 4 at
116
+ * the small) and ends it 2.5pt nearer the bottom edge at the regular size:
117
+ * the one-line baseline is kept instead, so a title does not move when it
118
+ * wraps, and a wrapped button is that much taller than AppKit's.
119
+ *
120
+ * The capitals are measured the way the title will be, `capBand` of the
121
+ * control font size, so the two cancel: a face whose trimmed caps round to
122
+ * the band — every UI face measured — fills the body exactly and never
123
+ * grows the control by a line it does not have.
124
+ */
125
+ export function nativeTitleMargins(nat, shadow, controlSize = 'regular') {
126
+ const body = nat.height - shadow.top - shadow.bottom;
127
+ const baseline = TITLE_BASELINE[controlSize];
128
+ const caps = capBand(CONTROL_FONT_SIZE[controlSize]);
129
+ return {
130
+ marginTop: Math.max(0, body - baseline - caps),
131
+ marginBottom: baseline,
132
+ };
133
+ }
134
+
104
135
  /**
105
136
  * The **footprint** a native control sits in: the box the caller's style
106
137
  * sizes, with the control — bezel, title and press wash, one unit at
@@ -108,9 +139,9 @@ export function nativeTitleStyle(controlSize = 'regular') {
108
139
  *
109
140
  * Two boxes rather than one, because the title is *placed* and not centred:
110
141
  * it rides `TITLE_BASELINE` above the bezel body's bottom edge, and that is
111
- * only where the cell puts it while the box is exactly `bezelNatural` tall.
112
- * The caller's style is applied last and flex stretches a box regardless, so
113
- * it was not always: `style={{ height: 44 }}`, a `height: '100%'` in a taller
142
+ * only where the cell puts it while the box is the control's own height. The
143
+ * caller's style is applied last and flex stretches a box regardless, so it
144
+ * was not always: `style={{ height: 44 }}`, a `height: '100%'` in a taller
114
145
  * parent and a bare `flexGrow: 1` each made the box taller, and the two
115
146
  * halves — the bezel absolutely filling it, the title glued to its bottom —
116
147
  * came apart, the label landing below the control it names (issue #510). The
@@ -118,20 +149,20 @@ export function nativeTitleStyle(controlSize = 'regular') {
118
149
  * control is merely roomy, where a stretched native one was broken.
119
150
  *
120
151
  * So the height a caller can move belongs to a box the control sits in, and
121
- * the control keeps AppKit's. The default here is exactly today's box — an
122
- * untouched control lays out as it always did, and the explicit height still
123
- * resists a row's align-stretch — and the caller's style, applied after it,
124
- * moves the footprint alone. The control stretches across it (align-stretch,
125
- * the default), so a footprint given a width is a bezel that wide.
152
+ * the control keeps its own. Untouched, the footprint is exactly as tall as
153
+ * the control — AppKit's height, or a button's taller one when its label
154
+ * wrapped — and the caller's style, applied after it, moves the footprint
155
+ * alone. A row's align-stretch moves it too, and the control stays centred
156
+ * in the slot, which is where a control that cannot stretch belongs beside a
157
+ * taller neighbour. The control stretches across it (align-stretch, the
158
+ * default), so a footprint given a width is a bezel that wide.
126
159
  *
127
160
  * The control, not the footprint, stays the *control*: the role, the
128
161
  * handlers, the focus ring and the ref go on it, so a press in the slack
129
162
  * above a 22pt popup in a 46pt hole does nothing at all — as it does in
130
163
  * AppKit — rather than firing a button with no visible answer to the press.
131
164
  */
132
- export function nativeFootprintStyle(nat) {
133
- return { height: nat.height, justifyContent: 'center' };
134
- }
165
+ export const NATIVE_FOOTPRINT = Object.freeze({ justifyContent: 'center' });
135
166
 
136
167
  /**
137
168
  * The rest of what a native control's own box says about its size: nothing
package/src/frames.js CHANGED
@@ -53,6 +53,12 @@ export function clearPendingFrame(node) {
53
53
  * - **Every window answers for itself.** The set is app-wide, so a click
54
54
  * that dirtied both a menu and the window under it paints both, each
55
55
  * gated on its own window's frame clock.
56
+ * - **Nor while a `<glarea>` of it waits on the clock.** A window's frame
57
+ * paints the overlay a surface's children are drawn on, and the clock
58
+ * draws the surface's own frame after it, in the same tick (#641). A
59
+ * window that says it owes one (`surfaceFrameOwed`) keeps its paced
60
+ * frame: painted now, the overlay would go out a refresh or more ahead
61
+ * of the GL frame it was painted for.
56
62
  *
57
63
  * A window whose scheduled frame this pays off keeps that scheduled
58
64
  * callback; it runs and finds `needsPaint` false, which is already a cheap
@@ -68,6 +74,7 @@ export function flushPendingFrames() {
68
74
  // Same for a headless mock window that models no frame clock at all.
69
75
  if (typeof wnd?.frameInFlight !== 'function') continue;
70
76
  if (wnd.frameInFlight()) continue;
77
+ if (wnd.surfaceFrameOwed?.()) continue;
71
78
  node.flush();
72
79
  }
73
80
  }
@@ -104,8 +104,9 @@ export const mainAxisOf = (node) => {
104
104
  /**
105
105
  * Whether `child` is one the floors are **written on** along `axis`: a flex
106
106
  * item on its container's main axis whose author left the minimum to the
107
- * content. The same test `writeFloors` applies, asked ahead of time — it is
108
- * what decides whether a stale extent is one anybody will read.
107
+ * content, and which flex could squeeze below it. The same test
108
+ * `writeFloors` applies, asked ahead of time — it is what decides whether a
109
+ * stale extent is one anybody will read.
109
110
  */
110
111
  export function receivesFloor(child, axis) {
111
112
  const parent = child.parent;
@@ -118,7 +119,37 @@ export function receivesFloor(child, axis) {
118
119
  return false;
119
120
  }
120
121
  const own = axis === 'width' ? 'minWidth' : 'minHeight';
121
- return typeof child.style[own] !== 'number';
122
+ return typeof child.style[own] !== 'number' && !floorIsNamedSize(child, axis);
123
+ }
124
+
125
+ /**
126
+ * Whether this item is its named size on `axis` in every pass, so that the
127
+ * floor a measurement would write is that same number and can never bind:
128
+ * a number for its size along the axis, as its flex basis (`flexBasis` unset
129
+ * or `'auto'`), `flexShrink: 0` and no `flexGrow`. Flex then takes its basis
130
+ * from that number and neither shrinks nor grows it, and measuring it is work
131
+ * nobody reads. A table's cells, at their columns' widths, are the case that
132
+ * paid: every row that scrolled into a `<Table>` was laid out alone for their
133
+ * floors before its frame could be, over half of a fling's frame time.
134
+ *
135
+ * A differential run over random trees, laid out with and without the
136
+ * rule, found why each clause is there. An auto basis is yoga's own
137
+ * measurement of the content, which can come out short of what the content
138
+ * needs, and the floor is what holds the item open. A named size that can
139
+ * grow is written as the size it grew to in the measuring pass, which the
140
+ * floor then keeps. And a `flexBasis` of its own is where the basis comes
141
+ * from instead of the size, and a percentage one is a different length in a
142
+ * measuring pass than in the real one.
143
+ */
144
+ function floorIsNamedSize(child, axis) {
145
+ const style = child.style;
146
+ const basis = style.flexBasis;
147
+ return (
148
+ style.flexShrink === 0 &&
149
+ !(style.flexGrow > 0) &&
150
+ (basis === undefined || basis === 'auto') &&
151
+ typeof (axis === 'width' ? style.width : style.height) === 'number'
152
+ );
122
153
  }
123
154
 
124
155
  /**
@@ -848,7 +879,12 @@ export function writeFloors(node, axis) {
848
879
  // A floor of 0 is what yoga does anyway, and an author who named their
849
880
  // own `minWidth`/`minHeight` has already answered — overwriting it would
850
881
  // put a measurement of ours above a number they wrote.
851
- if (axisIsMain && inFlow(child) && typeof child.style[own] !== 'number') {
882
+ if (
883
+ axisIsMain &&
884
+ inFlow(child) &&
885
+ typeof child.style[own] !== 'number' &&
886
+ !floorIsNamedSize(child, axis)
887
+ ) {
852
888
  const extent = horizontal ? child._floorW : child._floorH;
853
889
  if (extent > 0) floor = extent;
854
890
  }
@@ -847,14 +847,23 @@ export class WindowNode extends Scrollable(Node) {
847
847
  }
848
848
  if (geometryChanged) {
849
849
  if (typeof wnd.setState === 'function') {
850
+ // Only what this commit changed, as the branch below does. ntk sends
851
+ // whatever differs from what the server last reported, and under a
852
+ // reparenting window manager that is not what was asked for: a
853
+ // position comes back in the frame's coordinates, and a size is the
854
+ // one the user dragged. Handed the rest anyway, a resize re-sent the
855
+ // position — the window jumped from where the manager had placed it
856
+ // (or the user had moved it) to where the app first asked — and a move
857
+ // re-sent a size the user had changed.
850
858
  wnd.setState({
851
- x: anchored ? undefined : geo.x,
852
- y: anchored ? undefined : geo.y,
859
+ x: movedByProps ? geo.x : undefined,
860
+ y: movedByProps ? geo.y : undefined,
853
861
  // `'auto'` is not a geometry ntk can be given: an axis the app has
854
862
  // handed back is left alone here and resolved by `_refit()` on the
855
863
  // layout this same commit is about to schedule.
856
- width: isAutoSize(geo.width) ? undefined : geo.width,
857
- height: isAutoSize(geo.height) ? undefined : geo.height,
864
+ width: sizeChanged && !isAutoSize(geo.width) ? geo.width : undefined,
865
+ height:
866
+ sizeChanged && !isAutoSize(geo.height) ? geo.height : undefined,
858
867
  });
859
868
  } else {
860
869
  // A window with no `setState` — Cocoa's — is resized whole or not at
package/src/win32/a11y.js CHANGED
@@ -33,24 +33,21 @@ import {
33
33
  } from '../a11y.js';
34
34
  import { synthesizeClick } from '../events.js';
35
35
  import { onApp } from '../trace-registry.js';
36
+ import { A11yPacer } from '../a11ypace.js';
36
37
 
37
38
  const TRACE = process.env.REACT_X11_TRACE_A11Y === '1';
38
39
 
39
- /**
40
- * How often commits may push the mirror, in ms. A commit that follows a
41
- * quiet spell pushes at once; a stream of them — a pan, a drag, a value
42
- * ticking — pushes once per interval, the last of them catching up.
43
- *
44
- * UIA's clients are almost always listening on a desktop Windows (the
45
- * touch keyboard, the text services and others ask for the tree), so the
46
- * push is not the rare case the listening gate makes it elsewhere: a walk
47
- * of the window and a marshalled diff, on every commit. For a graph pane
48
- * whose every item moves on every step of a pan that was 1.6 ms of each
49
- * frame. A screen reader reads positions on demand and does not need
50
- * sixty of them a second; a focus change, which it is waiting on, still
51
- * pushes at once (`hooks.focus`), as does an announcement.
52
- */
53
- const COMMIT_PUSH_MS = 100;
40
+ // How often commits may push the mirror is src/a11ypace.js's to say, for
41
+ // this bridge and the AT-SPI one alike: at once after a quiet spell, and for
42
+ // a stream of commits (a pan, a drag, a value ticking) once per interval,
43
+ // the last push catching up. UIA's clients are almost always listening on a
44
+ // desktop Windows (the touch keyboard, the text services and others ask for
45
+ // the tree), so pushing is not the rare case the listening gate makes it
46
+ // elsewhere. For a graph pane whose every item moves on every step of a pan,
47
+ // a push per commit was 1.6 ms of each frame. A screen reader reads positions
48
+ // on demand and does not need sixty of them a second. What it does wait on
49
+ // is pushed at once: a focus change (`hooks.focus`), an announcement, and a
50
+ // change to the focused element itself (`_touch`).
54
51
 
55
52
  // --------------------------------------------------------------------------
56
53
  // The one translation that is Windows' own
@@ -316,9 +313,12 @@ export class Win32Accessibility {
316
313
  this._pushed = new Set();
317
314
  this._unsubscribes = [];
318
315
  this.dead = false;
319
- /** When a commit last pushed, and the push a stream of commits owes. */
320
- this._lastCommitPush = -Infinity;
321
- this._trailing = null;
316
+ /** When commits may push (src/a11ypace.js). */
317
+ this._pacer = new A11yPacer();
318
+ /** The focused node, and whether a commit changed it: its own changes
319
+ * are what a screen reader is waiting to hear, and are not paced. */
320
+ this._focused = null;
321
+ this._urgentPending = false;
322
322
  }
323
323
 
324
324
  _idOf(node) {
@@ -454,23 +454,21 @@ export class Win32Accessibility {
454
454
  }
455
455
  }
456
456
 
457
- /** A commit's push, at most once per `COMMIT_PUSH_MS`. */
457
+ /** A commit's push, paced (src/a11ypace.js), and at once where it
458
+ * changed the focused element. */
458
459
  _commitFlush() {
459
460
  if (this.dead || this._dirty.size === 0) return;
460
- const now = performance.now();
461
- const wait = this._lastCommitPush + COMMIT_PUSH_MS - now;
462
- if (wait <= 0) {
463
- this._lastCommitPush = now;
464
- this.flush();
465
- return;
461
+ const urgent = this._urgentPending;
462
+ this._urgentPending = false;
463
+ if (urgent) this._pacer.cancel();
464
+ if (urgent || this._pacer.ready(() => this._pacedFlush())) {
465
+ this._pacedFlush();
466
466
  }
467
- if (this._trailing) return;
468
- this._trailing = setTimeout(() => {
469
- this._trailing = null;
470
- this._lastCommitPush = performance.now();
471
- this.flush();
472
- }, wait);
473
- this._trailing.unref?.();
467
+ }
468
+
469
+ _pacedFlush() {
470
+ this._pacer.pushed();
471
+ this.flush();
474
472
  }
475
473
 
476
474
  /** A window's HWND exists now, so what is owed for it can be pushed. */
@@ -501,9 +499,19 @@ export class Win32Accessibility {
501
499
  return root && this.toplevels.includes(root) ? root : null;
502
500
  }
503
501
 
504
- _touch(node) {
502
+ /** `own`: the change is the node's own (its props or text), which is
503
+ * urgent where the node is, or is inside, the focused element. */
504
+ _touch(node, own = false) {
505
505
  const win = this._toplevelOf(node);
506
- if (win) this._dirty.add(win);
506
+ if (!win) return;
507
+ this._dirty.add(win);
508
+ if (!own || this._focused === null || this._urgentPending) return;
509
+ for (let n = node; n; n = n.parent) {
510
+ if (n === this._focused) {
511
+ this._urgentPending = true;
512
+ return;
513
+ }
514
+ }
507
515
  }
508
516
 
509
517
  // ---- what the shell asks for ------------------------------------------
@@ -594,10 +602,11 @@ export class Win32Accessibility {
594
602
  };
595
603
  hooks.attached = (parent) => this._touch(parent);
596
604
  hooks.detach = (parent) => this._touch(parent);
597
- hooks.propsChanged = (node) => this._touch(node);
598
- hooks.textContent = (chunk) => this._touch(chunk.parent ?? chunk);
599
- hooks.textState = (node) => this._touch(node);
605
+ hooks.propsChanged = (node) => this._touch(node, true);
606
+ hooks.textContent = (chunk) => this._touch(chunk.parent ?? chunk, true);
607
+ hooks.textState = (node) => this._touch(node, true);
600
608
  hooks.focus = (previous, next) => {
609
+ this._focused = next ?? null;
601
610
  const win = this._toplevelOf(next ?? previous);
602
611
  if (!win) return;
603
612
  this._dirty.add(win);
@@ -637,8 +646,7 @@ export class Win32Accessibility {
637
646
  bury() {
638
647
  if (this.dead) return;
639
648
  this.dead = true;
640
- if (this._trailing) clearTimeout(this._trailing);
641
- this._trailing = null;
649
+ this._pacer.cancel();
642
650
  for (const key of Object.keys(hooks)) hooks[key] = null;
643
651
  for (const unsubscribe of this._unsubscribes) unsubscribe();
644
652
  this._unsubscribes = [];