react-x11 1.2.0 → 2.0.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.
Files changed (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. package/src/scene3d.js +0 -683
package/src/scale.js ADDED
@@ -0,0 +1,626 @@
1
+ // The display scale: how many device pixels one logical pixel is worth.
2
+ //
3
+ // Every length an app writes — `width: 200`, `fontSize: 14`, the theme's
4
+ // spacing — is meant in *visible* pixels: units sized so that "14px text" is
5
+ // comfortably readable at the distance this kind of display is actually
6
+ // viewed from. On the panels the last decade shipped, one visible pixel is
7
+ // two device pixels (or 1.5, or 1.25), and a renderer that treats the two as
8
+ // the same unit draws every widget at half size. This module answers the one
9
+ // question that fixes that: **what is the factor, and how sure are we?**
10
+ //
11
+ // ## Where the answer can come from, and why this order
12
+ //
13
+ // X11 never grew a scale protocol, so the answer is scattered across four
14
+ // generations of convention. Each rung below is consulted only when the ones
15
+ // above it said nothing, and the order is "who is closest to a human having
16
+ // decided", not "who is most precise":
17
+ //
18
+ // 1. **Environment** — `REACT_X11_SCALE` (ours), then `GDK_SCALE` and
19
+ // `QT_SCALE_FACTOR` (the user already told their other toolkits; an app
20
+ // of ours on the same desktop should agree). A person typed these.
21
+ // 2. **XSETTINGS** — `Gdk/WindowScalingFactor` when it is 2 or more, then
22
+ // `Xft/DPI`. This is what the desktop's own settings dialog writes, and
23
+ // it is what every GTK app on the screen is already obeying — matching
24
+ // it is what makes us look native. `WindowScalingFactor: 1` is *not* an
25
+ // answer: it is the value daemons publish when nobody ever touched the
26
+ // dialog, and treating it as "the user chose 1x" is how a toolkit ends
27
+ // up microscopic on an unconfigured 4K laptop.
28
+ // 3. **`RESOURCE_MANAGER`** — `Xft.dpi`, the `xrdb` convention winit,
29
+ // Chromium and every terminal emulator read. Same caveat, sharper: 96
30
+ // exactly is the value of *never configured* (xfsettingsd writes it
31
+ // unconditionally — the machine this was developed on says `Xft.dpi:
32
+ // 96` while driving a 254dpi panel), so 96 falls through to the
33
+ // hardware and anything else is a person's decision.
34
+ // 4. **RandR millimetres** — the panel's physical size against its pixel
35
+ // size, the only rung that needs no configuration at all. This is
36
+ // mutter's model, constants and all: perceived size is angular, and a
37
+ // laptop is read at half the distance of a desk monitor, so the DPI
38
+ // that counts as "1x" is 135 under a 20" diagonal and 110 over it.
39
+ // The catch is that the millimetres are self-reported EDID data, and
40
+ // EDIDs lie in well-known ways — a projector reports zero, a KVM
41
+ // strips the block, cheap panels report their *aspect ratio* as a
42
+ // size, and **every virtual machine invents dimensions that make the
43
+ // maths land on ~96dpi** (QEMU hands a 16" MacBook panel to the guest
44
+ // as "870x550mm"). So the millimetres are audited before they are
45
+ // believed — see `classifyMm` — and the EDID vendor is read precisely
46
+ // to catch the VMs at it.
47
+ // 5. **The resolution class** — when the millimetres are absent or
48
+ // caught lying, the pixel grid itself is the last signal standing.
49
+ // Nobody makes a 1x panel 3456 pixels wide; a mode that size *is* a
50
+ // retina panel (or a VM window covering one, which wants the same
51
+ // answer). Only the confident call is made here — 2 for
52
+ // unmistakably-retina grids, 1 for everything else — because
53
+ // fractional guesses without physical data are how a UI ends up a
54
+ // subtly wrong size everywhere.
55
+ // 6. **1**, the answer X11 shipped with in 1987.
56
+ //
57
+ // A machine can defeat every rung above the last two — the one this was
58
+ // written against does: UTM in retina mode hands the guest the MacBook's
59
+ // full 3456x2168 grid, QEMU's EDID invents millimetres that read as 100dpi,
60
+ // and XFCE publishes the 96 it was never asked to change. Rungs 1-4 all say
61
+ // "1x" on that box and are all wrong. Rung 5 is why the ladder still lands
62
+ // on 2.
63
+ //
64
+ // ## Per monitor, then one for the root
65
+ //
66
+ // Rungs 4 and 5 are computed for every connected output, because a desktop
67
+ // with a retina laptop lid and an office monitor genuinely has two answers.
68
+ // The *root's* scale — the one layout and paint use — is the primary
69
+ // output's, matching what GNOME does on X11: one scale for the session,
70
+ // chosen for the display you called primary. The per-output answers ride on
71
+ // `useScreens()` so an app that places windows can do better, and a window
72
+ // can be pinned with `<window scale={n}>`. What this deliberately does not
73
+ // do is re-scale a window as it is dragged between mismatched monitors:
74
+ // X11 has one coordinate space and no per-window scale protocol, so that
75
+ // move is a resize the WM fights; Qt is the one toolkit that tries, and
76
+ // "static per window, chosen at creation" is the behaviour of everything
77
+ // else on this window system.
78
+ //
79
+ // Resolution happens once, inside `createRoot`, before the first window
80
+ // realizes — the scale multiplies CreateWindow geometry, so it cannot
81
+ // arrive later. The cost is honest: the environment is free, XSETTINGS is
82
+ // already being read for other reasons, and the RandR walk (three batched
83
+ // round trips) is only paid on desktops where nothing cheaper answered.
84
+ //
85
+ // `REACT_X11_DEBUG_SCALE=1` prints every rung's evidence and verdict.
86
+
87
+ import { beginXSettings } from './xsettings.js';
88
+
89
+ import { requireExtension } from './extensions.js';
90
+
91
+ const sessions = new WeakMap();
92
+
93
+ const debugScale = process.env.REACT_X11_DEBUG_SCALE === '1';
94
+ const trace = (...args) => {
95
+ if (debugScale) console.error('react-x11 scale:', ...args);
96
+ };
97
+
98
+ // --------------------------------------------------------------------------
99
+ // Small parsers, exported for the probe (`scripts/scale-probe.mjs`) and the
100
+ // tests — everything here is a pure function of bytes it was handed.
101
+ // --------------------------------------------------------------------------
102
+
103
+ /**
104
+ * `RESOURCE_MANAGER` is the text `xrdb` loaded, one `name: value` per line.
105
+ * Only the flat, fully-qualified names matter here (`Xft.dpi: 192`); the
106
+ * wildcard grammar (`*dpi`, `?`) is for matching against widget paths, which
107
+ * is a lookup this module never does.
108
+ */
109
+ export function parseResourceManager(text) {
110
+ const out = new Map();
111
+ if (typeof text !== 'string') return out;
112
+ for (const line of text.split('\n')) {
113
+ const colon = line.indexOf(':');
114
+ if (colon <= 0) continue;
115
+ const name = line.slice(0, colon).trim();
116
+ if (!name || name.startsWith('!')) continue;
117
+ out.set(name, line.slice(colon + 1).trim());
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /** PNP vendor id: three letters, five bits each, packed big-endian into
123
+ * bytes 8-9 of the EDID block. `A` is 1. */
124
+ function edidVendor(buffer) {
125
+ const raw = (buffer[8] << 8) | buffer[9];
126
+ const letter = (n) => String.fromCharCode(64 + ((raw >> n) & 0x1f));
127
+ const vendor = letter(10) + letter(5) + letter(0);
128
+ return /^[A-Z]{3}$/.test(vendor) ? vendor : null;
129
+ }
130
+
131
+ /**
132
+ * The EDID vendors and model strings that mean "this display is software".
133
+ *
134
+ * QEMU registered `RHT` (Red Hat); VMware, VirtualBox, Parallels and
135
+ * Hyper-V each have their own. The model-name check backs the vendor list
136
+ * up because nested and forked hypervisors ship EDIDs with the name kept
137
+ * and the vendor changed. Matching one of these does not make the *pixels*
138
+ * less real — it makes the *millimetres* fiction, because a VM's EDID
139
+ * describes a window, not a panel, and every hypervisor fills the size in
140
+ * with whatever makes ~96dpi come out.
141
+ */
142
+ const VIRTUAL_EDID_VENDORS = new Set([
143
+ 'RHT',
144
+ 'VMW',
145
+ 'VBX',
146
+ 'PRL',
147
+ 'MSF',
148
+ 'XEN',
149
+ ]);
150
+ const VIRTUAL_MODEL = /qemu|virtual|vbox|vmware|parallels|bochs|bhyve/i;
151
+
152
+ /**
153
+ * The 128-byte EDID base block → what the scale ladder wants from it:
154
+ * vendor, model name, the physical size, and the one derived judgement —
155
+ * `virtual` — that says the size is invented.
156
+ *
157
+ * Not a general EDID parser on purpose. Detailed timing descriptors carry a
158
+ * second, finer physical size, but a lying EDID lies in both places, so
159
+ * reading it would add code and no information.
160
+ */
161
+ export function parseEdid(buffer) {
162
+ if (!buffer || buffer.length < 128) return null;
163
+ // the fixed 8-byte header; anything else is not an EDID
164
+ if (
165
+ buffer[0] !== 0x00 ||
166
+ buffer[1] !== 0xff ||
167
+ buffer[6] !== 0xff ||
168
+ buffer[7] !== 0x00
169
+ ) {
170
+ return null;
171
+ }
172
+ const vendor = edidVendor(buffer);
173
+ // bytes 21/22: maximum image size in whole centimetres; 0 means "unknown
174
+ // or variable", which projectors use honestly and KVMs use lazily
175
+ const mmWidth = buffer[21] ? buffer[21] * 10 : null;
176
+ const mmHeight = buffer[22] ? buffer[22] * 10 : null;
177
+ let model = null;
178
+ // four 18-byte descriptors; 0xFC is the display product name
179
+ for (let at = 54; at + 18 <= 126; at += 18) {
180
+ if (buffer[at] === 0 && buffer[at + 1] === 0 && buffer[at + 3] === 0xfc) {
181
+ model = buffer
182
+ .toString('latin1', at + 5, at + 18)
183
+ .split('\n')[0]
184
+ .trim();
185
+ break;
186
+ }
187
+ }
188
+ const virtual =
189
+ (vendor !== null && VIRTUAL_EDID_VENDORS.has(vendor)) ||
190
+ (model !== null && VIRTUAL_MODEL.test(model));
191
+ return { vendor, model, mmWidth, mmHeight, virtual };
192
+ }
193
+
194
+ // --------------------------------------------------------------------------
195
+ // Judging a monitor's metadata
196
+ // --------------------------------------------------------------------------
197
+
198
+ /** Output names that mean the display is software even when no EDID says so:
199
+ * QEMU's virtio connector, VirtualBox's, VMware's, qxl. `XWAYLAND` is here
200
+ * for a different reason — those millimetres are usually *true*, but the
201
+ * compositor owns scaling on that path and publishes its decision through
202
+ * XSETTINGS, so hardware inference would double what rung 2 already knows. */
203
+ const VIRTUAL_OUTPUT_NAME =
204
+ /^(Virtual|VIRTUAL|VBOX|VMWARE|qxl|hyperv|XWAYLAND)/i;
205
+
206
+ /**
207
+ * Can these millimetres be trusted to compute a density? Returns
208
+ * `'credible'` or the reason they cannot be:
209
+ *
210
+ * 'absent' zero or missing — projectors, stripped EDIDs
211
+ * 'virtual' a VM's EDID or connector; the size is invented
212
+ * 'aspect-as-size' the panel wrote its aspect ratio where the size goes
213
+ * (16x9 "millimetres" — a real panel the size of a
214
+ * matchbook does not exist)
215
+ * 'aspect-mismatch' the physical and pixel aspect ratios disagree by more
216
+ * than a quarter — one of them is wrong and there is no
217
+ * way to know which
218
+ *
219
+ * A *huge* diagonal is deliberately not a reason: a 60" panel is a real
220
+ * thing with real millimetres, and the viewing-distance model below already
221
+ * answers it with 1x. The audit here is only about lies.
222
+ */
223
+ export function classifyMm(output, crtc) {
224
+ const mmW = output?.mm_width ?? output?.widthMM ?? 0;
225
+ const mmH = output?.mm_height ?? output?.heightMM ?? 0;
226
+ const name = output?.name ?? '';
227
+ const edid = output?.edid ?? null;
228
+ if (edid?.virtual || VIRTUAL_OUTPUT_NAME.test(name)) return 'virtual';
229
+ if (!(mmW > 0) || !(mmH > 0)) return 'absent';
230
+ // the classic junk values: an aspect ratio in a size's clothing
231
+ if (mmW <= 16 && mmH <= 16) return 'aspect-as-size';
232
+ if ((mmW === 160 && mmH === 90) || (mmW === 160 && mmH === 100))
233
+ return 'aspect-as-size';
234
+ const pxW = crtc?.width ?? output?.width ?? 0;
235
+ const pxH = crtc?.height ?? output?.height ?? 0;
236
+ if (pxW > 0 && pxH > 0) {
237
+ // compare in a rotation-proof way: a portrait CRTC on a landscape panel
238
+ // is a real desk arrangement, not a lie
239
+ // 0.15 is picked to pass a panel whose EDID measured the module with
240
+ // its bezel (16:9 pixels on 16:10-ish glass is ~0.11 off) and fail the
241
+ // classic lie of 4:3 millimetres on a 16:10 grid (~0.2 off).
242
+ const pxAspect = Math.max(pxW, pxH) / Math.min(pxW, pxH);
243
+ const mmAspect = Math.max(mmW, mmH) / Math.min(mmW, mmH);
244
+ if (Math.abs(pxAspect - mmAspect) / mmAspect > 0.15)
245
+ return 'aspect-mismatch';
246
+ }
247
+ return 'credible';
248
+ }
249
+
250
+ /**
251
+ * mutter's perceptual model, constants and all: the DPI that reads as "1x"
252
+ * depends on viewing distance, and diagonal size is the proxy for distance
253
+ * that actually ships. Under 20 inches the panel is in your lap at ~50cm
254
+ * and 135dpi is the baseline; over it the panel is across a desk and 110
255
+ * is. (For calibration: a 27" 2560x1440 desk monitor computes 109dpi → 1x;
256
+ * a 16" MacBook panel computes 255dpi → 1.9 → 2x; a 13" 1920x1080 laptop
257
+ * computes 169dpi → 1.25.)
258
+ */
259
+ const TARGET_DPI_MOBILE = 135;
260
+ const TARGET_DPI_LARGE = 110;
261
+ const MOBILE_DIAGONAL_INCHES = 20;
262
+
263
+ /** Snap to the quarter steps every desktop offers, inside [1, 3]. Quarters
264
+ * are what the plumbing downstream can draw crisply — layout snaps to the
265
+ * device grid through yoga's point scale — and three doubles the largest
266
+ * factor any shipping desktop configures. */
267
+ export function snapScale(value) {
268
+ if (!Number.isFinite(value)) return 1;
269
+ return Math.min(3, Math.max(1, Math.round(value * 4) / 4));
270
+ }
271
+
272
+ /**
273
+ * One monitor's metadata → `{ scale, source, reason }`, using only what the
274
+ * connection reported: pixel geometry, claimed millimetres, EDID. This is
275
+ * rungs 4 and 5 of the ladder for one output; the caller stacks the
276
+ * desktop-configuration rungs above it.
277
+ */
278
+ export function monitorScaleFromMetadata(monitor) {
279
+ const mm = classifyMm(monitor, monitor);
280
+ const pxW = monitor.width ?? 0;
281
+ const pxH = monitor.height ?? 0;
282
+ const mmW = monitor.widthMM ?? monitor.mm_width ?? 0;
283
+ const mmH = monitor.heightMM ?? monitor.mm_height ?? 0;
284
+
285
+ if (mm === 'credible' && pxW > 0) {
286
+ const diagonalInches = Math.hypot(mmW, mmH) / 25.4;
287
+ // long pixel axis over long physical axis, so a portrait CRTC on a
288
+ // landscape panel measures the same density as its neighbour
289
+ const dpi = Math.max(pxW, pxH) / (Math.max(mmW, mmH) / 25.4);
290
+ const target =
291
+ diagonalInches < MOBILE_DIAGONAL_INCHES
292
+ ? TARGET_DPI_MOBILE
293
+ : TARGET_DPI_LARGE;
294
+ const scale = snapScale(dpi / target);
295
+ return {
296
+ scale,
297
+ source: 'randr-mm',
298
+ reason: `${Math.round(dpi)}dpi across ${diagonalInches.toFixed(1)}" (target ${target})`,
299
+ };
300
+ }
301
+
302
+ // No physical truth to reason from. The pixel grid alone still separates
303
+ // "unmistakably a retina panel" from everything else: the smallest grids
304
+ // this matches are 2880x1800 and 3024x1964, both shipped only as 2x
305
+ // panels, and every VM window covering one lands here too. 2560-wide
306
+ // grids stay at 1 on purpose — 2560x1440 is the commonest *1x* desk
307
+ // monitor there is, and only millimetres could tell it from a 13" retina
308
+ // lid, which is exactly the data this branch does not have.
309
+ if (Math.min(pxW, pxH) >= 1800 || Math.max(pxW, pxH) >= 3000) {
310
+ return {
311
+ scale: 2,
312
+ source: 'resolution',
313
+ reason: `${pxW}x${pxH} is a retina-class grid (mm ${mm})`,
314
+ };
315
+ }
316
+ return {
317
+ scale: 1,
318
+ source: 'default',
319
+ reason: `no credible density data (mm ${mm}, ${pxW}x${pxH})`,
320
+ };
321
+ }
322
+
323
+ // --------------------------------------------------------------------------
324
+ // The desktop-configuration rungs
325
+ // --------------------------------------------------------------------------
326
+
327
+ function envScale() {
328
+ const own = Number(process.env.REACT_X11_SCALE);
329
+ // wider bounds than `snapScale` on purpose: an explicit override is a
330
+ // person telling us, and 0.5 ("shrink it, my panel is dense and my eyes
331
+ // are good") is a thing people legitimately ask toolkits for
332
+ if (Number.isFinite(own) && own >= 0.5 && own <= 8) {
333
+ return { scale: own, source: 'REACT_X11_SCALE' };
334
+ }
335
+ const gdk = Number(process.env.GDK_SCALE);
336
+ if (Number.isInteger(gdk) && gdk >= 1 && gdk <= 8 && gdk !== 1) {
337
+ return { scale: gdk, source: 'GDK_SCALE' };
338
+ }
339
+ const qt = Number(process.env.QT_SCALE_FACTOR);
340
+ if (Number.isFinite(qt) && qt > 0 && qt <= 8 && qt !== 1) {
341
+ return { scale: qt, source: 'QT_SCALE_FACTOR' };
342
+ }
343
+ return null;
344
+ }
345
+
346
+ /**
347
+ * `Xft/DPI` on the wire is 1024ths of a dot per inch — the machine this
348
+ * was written on publishes 98304, which is 96 — but daemons writing plain
349
+ * DPI exist too, and no plausible density is over 1024, so the magnitude
350
+ * itself says which convention the daemon used.
351
+ */
352
+ function dpiFromXftValue(value) {
353
+ if (typeof value !== 'number' || value <= 0) return null;
354
+ return value > 1024 ? value / 1024 : value;
355
+ }
356
+
357
+ /** Rung 2 as a pure function of the XSETTINGS map — exported for the tests. */
358
+ export function desktopScaleFromXSettings(map) {
359
+ if (!map) return null;
360
+ const factor = map.get('Gdk/WindowScalingFactor');
361
+ // 2 and up is a decision; 1 is what the dialog says before anyone opens it
362
+ if (Number.isInteger(factor) && factor >= 2) {
363
+ return { scale: Math.min(factor, 8), source: 'Gdk/WindowScalingFactor' };
364
+ }
365
+ const dpi = dpiFromXftValue(map.get('Xft/DPI'));
366
+ if (dpi !== null && Math.round(dpi) !== 96) {
367
+ return {
368
+ scale: snapScale(dpi / 96),
369
+ source: `Xft/DPI (${Math.round(dpi)})`,
370
+ };
371
+ }
372
+ return null;
373
+ }
374
+
375
+ /** Rung 3 as a pure function of the parsed resource map — for the tests. */
376
+ export function desktopScaleFromResources(resources) {
377
+ const dpi = Number(resources.get('Xft.dpi'));
378
+ if (Number.isFinite(dpi) && dpi > 0 && Math.round(dpi) !== 96) {
379
+ return {
380
+ scale: snapScale(dpi / 96),
381
+ source: `Xft.dpi (${Math.round(dpi)})`,
382
+ };
383
+ }
384
+ return null;
385
+ }
386
+
387
+ // --------------------------------------------------------------------------
388
+ // Reading the connection
389
+ // --------------------------------------------------------------------------
390
+
391
+ /** A node-x11 request as a promise that resolves null on error, because
392
+ * every read here is a rung that is allowed to answer nothing. */
393
+ function call(fn, ...args) {
394
+ return new Promise((resolve) => {
395
+ try {
396
+ fn(...args, (err, value) => resolve(err ? null : value));
397
+ } catch {
398
+ resolve(null);
399
+ }
400
+ });
401
+ }
402
+
403
+ const RESOURCE_MANAGER_ATOM = 23; // predefined, like the STRING type it holds
404
+
405
+ async function readResourceManager(X, root) {
406
+ const prop = await call(
407
+ X.GetProperty.bind(X),
408
+ 0,
409
+ root,
410
+ RESOURCE_MANAGER_ATOM,
411
+ 0,
412
+ 0,
413
+ 0x1fffffff,
414
+ );
415
+ if (!prop?.data?.length) return new Map();
416
+ return parseResourceManager(prop.data.toString('latin1'));
417
+ }
418
+
419
+ /**
420
+ * The hardware walk: every connected output's pixel geometry, claimed
421
+ * millimetres and EDID, in three batched round trips (resources, then all
422
+ * output infos at once, then all CRTCs and EDIDs at once). Only run when
423
+ * every configured rung came up empty.
424
+ */
425
+ async function readOutputs(app) {
426
+ const randr = await requireExtension(app, 'randr');
427
+ const X = app.X;
428
+ const root = X.display?.screen?.[0]?.root;
429
+ if (!randr || root == null) return null;
430
+ const resources = await call(randr.GetScreenResourcesCurrent, root);
431
+ if (!resources) return null;
432
+ const primary = await call(randr.GetOutputPrimary, root);
433
+ const edidAtom = await new Promise((resolve) =>
434
+ X.InternAtom(false, 'EDID', (err, atom) => resolve(err ? null : atom)),
435
+ );
436
+ const infos = (
437
+ await Promise.all(
438
+ (resources.outputs ?? []).map((id) =>
439
+ call(randr.GetOutputInfo, id, resources.config_timestamp).then(
440
+ (info) => (info ? { ...info, id } : null),
441
+ ),
442
+ ),
443
+ )
444
+ ).filter((info) => info && info.connection === 0 && info.crtc);
445
+ const monitors = await Promise.all(
446
+ infos.map(async (info) => {
447
+ const [crtc, edidProp] = await Promise.all([
448
+ call(randr.GetCrtcInfo, info.crtc, resources.config_timestamp),
449
+ edidAtom
450
+ ? call(randr.GetOutputProperty, info.id, edidAtom, 0, 0, 64, 0, 0)
451
+ : null,
452
+ ]);
453
+ if (!crtc || !(crtc.width > 0)) return null;
454
+ return {
455
+ name: info.name,
456
+ primary: info.id === primary,
457
+ x: crtc.x,
458
+ y: crtc.y,
459
+ width: crtc.width,
460
+ height: crtc.height,
461
+ widthMM: info.mm_width,
462
+ heightMM: info.mm_height,
463
+ edid: edidProp?.data?.length >= 128 ? parseEdid(edidProp.data) : null,
464
+ };
465
+ }),
466
+ );
467
+ const connected = monitors.filter(Boolean);
468
+ return connected.length ? connected : null;
469
+ }
470
+
471
+ // --------------------------------------------------------------------------
472
+ // The session
473
+ // --------------------------------------------------------------------------
474
+
475
+ class ScaleSession {
476
+ constructor() {
477
+ /** The root's factor — what layout, fonts and paint multiply by. */
478
+ this.scale = 1;
479
+ /** Which rung answered, for `REACT_X11_DEBUG_SCALE` and the tests. */
480
+ this.source = 'default';
481
+ /** Per-output verdicts, keyed by RandR output name, for `useScreens`
482
+ * and for windows placed by an app that knows better than "primary". */
483
+ this.monitors = new Map();
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Resolve the scale for this connection. Called from `createRoot`, awaited,
489
+ * before any window realizes — the factor multiplies CreateWindow geometry,
490
+ * so it has to be settled first and stay settled (see the header for why it
491
+ * is static).
492
+ *
493
+ * `option` is `createRoot`'s `scale`: a number pins it (clamped to [0.5, 8]
494
+ * — past those bounds it is a typo, not a preference), `'auto'` or
495
+ * `undefined` climbs the ladder. `REACT_X11_SCALE` outranks even the
496
+ * explicit number, because the person running the app outranks the person
497
+ * who wrote it — that is the accessibility escape hatch when a hardcoded
498
+ * `scale: 1` meets a screen it is wrong on.
499
+ */
500
+ export async function beginScale(app, option) {
501
+ let session = sessions.get(app);
502
+ if (session) return session;
503
+ session = new ScaleSession();
504
+ sessions.set(app, session);
505
+
506
+ const own = Number(process.env.REACT_X11_SCALE);
507
+ if (Number.isFinite(own) && own >= 0.5 && own <= 8) {
508
+ session.scale = own;
509
+ session.source = 'REACT_X11_SCALE';
510
+ trace(`${session.scale}x from REACT_X11_SCALE`);
511
+ return session;
512
+ }
513
+
514
+ if (typeof option === 'number' && Number.isFinite(option)) {
515
+ session.scale = Math.min(8, Math.max(0.5, option));
516
+ session.source = 'option';
517
+ trace(`${session.scale}x from createRoot({ scale })`);
518
+ return session;
519
+ }
520
+
521
+ const X = app?.X;
522
+ const root = X?.display?.screen?.[0]?.root;
523
+ if (!X || typeof X.GetProperty !== 'function' || root == null) {
524
+ // the headless mock: tests mean their numbers literally
525
+ return session;
526
+ }
527
+
528
+ const env = envScale();
529
+ if (env) {
530
+ session.scale = env.scale;
531
+ session.source = env.source;
532
+ trace(`${env.scale}x from ${env.source}`);
533
+ return session;
534
+ }
535
+
536
+ // Rung 2: the desktop's own channel. `beginXSettings` is idempotent and
537
+ // shared with appearance/desktopsettings; awaiting it here costs the one
538
+ // selection-owner round trip this path needed anyway.
539
+ const xsettings = await beginXSettings(app);
540
+ const fromDaemon = desktopScaleFromXSettings(xsettings?.values);
541
+ if (fromDaemon) {
542
+ session.scale = fromDaemon.scale;
543
+ session.source = fromDaemon.source;
544
+ trace(`${fromDaemon.scale}x from XSETTINGS ${fromDaemon.source}`);
545
+ return session;
546
+ }
547
+
548
+ // Rung 3: xrdb.
549
+ const resources = await readResourceManager(X, root);
550
+ const fromXrdb = desktopScaleFromResources(resources);
551
+ if (fromXrdb) {
552
+ session.scale = fromXrdb.scale;
553
+ session.source = fromXrdb.source;
554
+ trace(`${fromXrdb.scale}x from RESOURCE_MANAGER ${fromXrdb.source}`);
555
+ return session;
556
+ }
557
+
558
+ // Rungs 4-5: the hardware, one verdict per output.
559
+ const outputs = await readOutputs(app);
560
+ if (outputs) {
561
+ for (const monitor of outputs) {
562
+ const verdict = monitorScaleFromMetadata(monitor);
563
+ session.monitors.set(monitor.name, {
564
+ scale: verdict.scale,
565
+ source: verdict.source,
566
+ primary: monitor.primary,
567
+ x: monitor.x,
568
+ y: monitor.y,
569
+ width: monitor.width,
570
+ height: monitor.height,
571
+ });
572
+ trace(
573
+ ` ${monitor.name}${monitor.primary ? ' (primary)' : ''}: ` +
574
+ `${verdict.scale}x via ${verdict.source} — ${verdict.reason}`,
575
+ );
576
+ }
577
+ // The root's answer: the primary output's, like GNOME on X11. No
578
+ // primary flag is a desktop that never ran `xrandr --primary`; the
579
+ // largest output is the best stand-in for "the one you look at".
580
+ const chosen =
581
+ [...session.monitors.values()].find((m) => m.primary) ??
582
+ [...session.monitors.values()].sort(
583
+ (a, b) => b.width * b.height - a.width * a.height,
584
+ )[0];
585
+ if (chosen) {
586
+ session.scale = chosen.scale;
587
+ session.source = chosen.source;
588
+ }
589
+ }
590
+ trace(`${session.scale}x from ${session.source}`);
591
+ return session;
592
+ }
593
+
594
+ /**
595
+ * The resolved factor for this connection — 1 until `beginScale` settles,
596
+ * which `createRoot` guarantees happened before anything renders.
597
+ * Synchronous because its callers are: styles apply inside React's commit,
598
+ * paint runs inside the frame clock, and neither has a round trip to spend.
599
+ */
600
+ export function scaleOf(app) {
601
+ return sessions.get(app)?.scale ?? 1;
602
+ }
603
+
604
+ /** Which rung answered, for tests and the debug overlay. */
605
+ export function scaleSourceOf(app) {
606
+ return sessions.get(app)?.source ?? 'default';
607
+ }
608
+
609
+ /** Per-output verdicts (name → {scale, source, primary, geometry}), for
610
+ * `useScreens` to join onto its monitor list. Empty on desktops where a
611
+ * configured rung answered — one factor is the whole story there. */
612
+ export function monitorScalesOf(app) {
613
+ return sessions.get(app)?.monitors ?? new Map();
614
+ }
615
+
616
+ /** Tests pin the factor without a connection. */
617
+ export function setScaleForTests(app, scale, source = 'test') {
618
+ let session = sessions.get(app);
619
+ if (!session) {
620
+ session = new ScaleSession();
621
+ sessions.set(app, session);
622
+ }
623
+ session.scale = scale;
624
+ session.source = source;
625
+ return session;
626
+ }
@@ -0,0 +1,27 @@
1
+ // The display scale, as rendering code reads it.
2
+ //
3
+ // One number and it never changes — `createRoot` resolves it before the
4
+ // first window realizes and it is static for the life of the connection
5
+ // (src/scale.js explains why) — so this is the rare hook with nothing to
6
+ // subscribe to. It exists because the number is still *occasionally* an
7
+ // app's business even though every style, event and rect already speaks
8
+ // logical pixels: a `<canvas onDraw>` sizing its backing detail, a
9
+ // screenshot tool captioning what it captured, a settings pane showing
10
+ // "2x (from Xft.dpi)" the way it shows the DPI.
11
+
12
+ import { useApp } from './appcontext.js';
13
+ import { scaleOf } from './scale.js';
14
+
15
+ /**
16
+ * Device pixels per logical pixel for this root — `1` on an ordinary
17
+ * display, `2` on the retina panel this feature was built against,
18
+ * fractional on the desktops that configure 1.25/1.5.
19
+ *
20
+ * Everything the renderer hands an app is already logical (styles, event
21
+ * coordinates, `getClientRects`, `useScreens`), so multiply by this only
22
+ * to reach *device* pixels deliberately — the `<canvas onDraw>` payload
23
+ * carries the same number as `scale` for exactly that.
24
+ */
25
+ export function useScale() {
26
+ return scaleOf(useApp());
27
+ }