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.
- package/README.md +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- 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
|
+
}
|