react-x11 2.51.0 → 2.52.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 +87 -54
- package/docs/README.md +410 -0
- package/docs/accessibility.md +628 -0
- package/docs/appearance.md +416 -0
- package/docs/architecture/accessibility.md +470 -0
- package/docs/architecture/animation.md +707 -0
- package/docs/architecture/container-queries.md +525 -0
- package/docs/architecture/custom-layout.md +334 -0
- package/docs/architecture/drag-and-drop.md +941 -0
- package/docs/architecture/element-layer-contents.md +622 -0
- package/docs/architecture/frame-pacing.md +255 -0
- package/docs/architecture/grid-layout.md +358 -0
- package/docs/architecture/protocol-efficiency.md +357 -0
- package/docs/architecture/video.md +1149 -0
- package/docs/architecture/windowed-regions.md +854 -0
- package/docs/click-to-component.md +125 -0
- package/docs/clipboard.md +281 -0
- package/docs/components.md +1749 -0
- package/docs/dbus.md +277 -0
- package/docs/debugging.md +464 -0
- package/docs/desktop-calendar.md +284 -0
- package/docs/desktop.md +648 -0
- package/docs/devtools.md +250 -0
- package/docs/drag-and-drop.md +663 -0
- package/docs/ecosystem/animation.md +394 -0
- package/docs/ecosystem/data-fetching.md +201 -0
- package/docs/ecosystem/dev-tooling.md +436 -0
- package/docs/ecosystem/forms.md +313 -0
- package/docs/ecosystem/headless.md +219 -0
- package/docs/ecosystem/i18n.md +89 -0
- package/docs/ecosystem/icons.md +410 -0
- package/docs/ecosystem/layout.md +216 -0
- package/docs/ecosystem/routing.md +146 -0
- package/docs/ecosystem/state.md +361 -0
- package/docs/ecosystem/testing.md +500 -0
- package/docs/ecosystem/theming.md +540 -0
- package/docs/ecosystem.md +473 -0
- package/docs/elements.md +2762 -0
- package/docs/embedding.md +288 -0
- package/docs/events.md +1025 -0
- package/docs/extending.md +2242 -0
- package/docs/eyedropper.md +223 -0
- package/docs/filedialog.md +258 -0
- package/docs/frame.md +580 -0
- package/docs/gl.md +170 -0
- package/docs/globalmenu.md +278 -0
- package/docs/glx.md +137 -0
- package/docs/macos.md +3295 -0
- package/docs/notifications.md +164 -0
- package/docs/packaging.md +611 -0
- package/docs/permissions.md +148 -0
- package/docs/react-features.md +430 -0
- package/docs/remote.md +223 -0
- package/docs/scale.md +339 -0
- package/docs/security.md +132 -0
- package/docs/styling.md +1928 -0
- package/docs/system.md +599 -0
- package/docs/testing.md +514 -0
- package/docs/typescript.md +117 -0
- package/docs/uri-schemes.md +380 -0
- package/docs/wayland-backend.md +591 -0
- package/docs/wayland.md +545 -0
- package/docs/windows-embedding.md +271 -0
- package/docs/windows-gl.md +221 -0
- package/docs/windows-integrations.md +233 -0
- package/docs/windows.md +1404 -0
- package/package.json +10 -3
- package/src/bus.js +39 -26
- package/src/cli/create.d.ts +43 -0
- package/src/cli/create.js +305 -0
- package/src/cli/env.js +484 -0
- package/src/cli/index.js +29 -2
- package/src/cli/templates/AGENTS.template.md +195 -0
- package/src/cli/templates/DESIGN.template.md +269 -0
- package/src/desktopintegration.js +2 -1
- package/src/desktopsettings.js +193 -24
- package/src/filedialog.js +3 -3
- package/src/keyboardstate.js +28 -0
- package/src/node.d.ts +10 -10
- package/src/portal.js +31 -0
- package/src/screencolor.js +5 -10
- package/src/types/system.d.ts +8 -6
- package/src/wayland/app.js +59 -0
- package/src/wayland/clipboard.js +14 -7
- package/src/wayland/connection.js +3 -0
- package/src/wayland/context2d.js +129 -10
- package/src/wayland/protocols/index.json +6 -0
- package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
- package/src/wayland/seat.js +28 -1
- package/src/wayland/swapchain.js +23 -0
- package/src/wayland/xkb.js +137 -0
package/docs/remote.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# Remote display
|
|
2
|
+
|
|
3
|
+
This is the case the architecture is categorically better at, and the one
|
|
4
|
+
nothing else on the page has told you about.
|
|
5
|
+
|
|
6
|
+
A react-x11 app **draws over the wire**. It does not render pixels and ship
|
|
7
|
+
them; it sends X11 drawing requests — rectangles, glyph runs, composites —
|
|
8
|
+
and the X server on the far end turns them into pixels. So "running the app
|
|
9
|
+
somewhere else" is not a feature bolted on top. It is what the protocol is,
|
|
10
|
+
and the program does not know the difference.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
ssh -X buildbox
|
|
14
|
+
npm start # the window opens on your desktop
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Nothing in the app changes. No VNC, no browser, no streaming daemon, no port
|
|
18
|
+
to forward, no second copy of the UI.
|
|
19
|
+
|
|
20
|
+
**This is the X11 backend**, and it is the reason X11 stays the flagship
|
|
21
|
+
target rather than becoming the legacy one: the Cocoa, win32 and Wayland
|
|
22
|
+
backends have no wire at all, so nothing on this page applies to them. That
|
|
23
|
+
matters in one specific case — **an app running on a Mac or on Windows and
|
|
24
|
+
drawing somewhere else**. `$DISPLAY` in the environment does not choose the
|
|
25
|
+
backend there; `backend: 'auto'` sees darwin and picks Cocoa, or win32 and
|
|
26
|
+
picks the win32 backend, and the window opens on the local machine. Say
|
|
27
|
+
which you want:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
REACT_X11_BACKEND=x11 DISPLAY=thin-client:0 npm start
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`createRoot({ display: … })` or `createRoot({ stream: … })` also settle it —
|
|
34
|
+
naming an X endpoint in code _is_ choosing X11, because an ignored
|
|
35
|
+
connection option would be worse than either answer. An app running on Linux
|
|
36
|
+
and drawing to a Mac's XQuartz is not affected: both ends are X11 already.
|
|
37
|
+
|
|
38
|
+
## What it costs, measured
|
|
39
|
+
|
|
40
|
+
The honest comparison is with pixel streaming (VNC, RDP, a
|
|
41
|
+
`--remote-debugging` browser), because that is what people reach for
|
|
42
|
+
instead. The trade is bytes-per-frame against round-trips-per-interaction.
|
|
43
|
+
|
|
44
|
+
From `scripts/bench/baseline.json`, which measures against node-x11's
|
|
45
|
+
in-process server — so these are exact protocol costs, not estimates:
|
|
46
|
+
|
|
47
|
+
| scenario | requests | bytes out | replies | stalls |
|
|
48
|
+
| ---------------------------------- | -------- | --------- | ------- | ------ |
|
|
49
|
+
| mount: a window, 40 boxes + labels | 110 | 4.2 kB | 32 | 0 |
|
|
50
|
+
| 50 filled rounded boxes | 122 | 6.6 kB | 7 | 0 |
|
|
51
|
+
| a paragraph of text | 39 | 6.0 kB | 7 | 0 |
|
|
52
|
+
| 10 wheel notches over 500 rows | 224 | 11.2 kB | 22 | 4 |
|
|
53
|
+
| 5 drag steps of a card of widgets | 72 | 4.3 kB | 11 | 0 |
|
|
54
|
+
|
|
55
|
+
**A whole window appears for about four kilobytes.** A single 1280×800 frame
|
|
56
|
+
of VNC, even well compressed, is tens to hundreds of kilobytes, and it is
|
|
57
|
+
sent again every time anything moves. Scrolling a 500-row table ten notches
|
|
58
|
+
costs 11 kB here because only the damaged strip is redrawn (the
|
|
59
|
+
[scroll-blit path](../AGENTS.md), issue #138); the same scroll under pixel
|
|
60
|
+
streaming is ten full frames. Dragging a card with a background of its own
|
|
61
|
+
is the same trade: each step copies the card where it went and draws the
|
|
62
|
+
strip it uncovered, so its widgets are not drawn again
|
|
63
|
+
([extending.md](extending.md#an-element-that-covers-its-box), issue #681) —
|
|
64
|
+
the five steps in the table would be 24 kB drawn.
|
|
65
|
+
|
|
66
|
+
What react-x11 pays instead is **latency sensitivity**. Bandwidth is not the
|
|
67
|
+
scarce resource on a link like this — round trips are. A request that waits
|
|
68
|
+
for a reply stalls the pipeline for a full RTT, so on a 60 ms link every
|
|
69
|
+
reply is 60 ms of nothing.
|
|
70
|
+
|
|
71
|
+
The last two columns are how that is watched, and the difference between
|
|
72
|
+
them is the whole point. `replies` counts requests that carry an answer;
|
|
73
|
+
`stalls` counts the ones whose reply arrived with **nothing pipelined behind
|
|
74
|
+
them** — the request was the last thing sent, so the connection really did
|
|
75
|
+
sit idle for a round trip. A pipelined reply costs a reply and no time: the
|
|
76
|
+
mount's 32 are almost all `InternAtom`, fired off together while the window
|
|
77
|
+
is built, and it stalls on none of them. (The bench's own `settle()` fences
|
|
78
|
+
are a fixed floor in that column, so read growth rather than the absolute
|
|
79
|
+
number.)
|
|
80
|
+
|
|
81
|
+
So the rule — "avoid round trips", rule 4 in
|
|
82
|
+
[AGENTS.md](../AGENTS.md#protocol-efficiency) — is really "avoid _blocking_
|
|
83
|
+
round trips", which is why atoms, geometry and glyph pages are cached rather
|
|
84
|
+
than re-asked, and why `stalls` is the column to watch on a slow link.
|
|
85
|
+
`npm run bench -- --hotspots` attributes them, request by request.
|
|
86
|
+
|
|
87
|
+
Rules of thumb, from the shape of the protocol rather than from a benchmark
|
|
88
|
+
we have not run:
|
|
89
|
+
|
|
90
|
+
- **Under ~20 ms RTT** (a LAN, a nearby VPS): indistinguishable from local.
|
|
91
|
+
- **20–80 ms** (a continent away): typing and scrolling stay responsive;
|
|
92
|
+
window mapping and font setup have a visible beat.
|
|
93
|
+
- **Above ~150 ms** or on a lossy link: X11's chattiness starts to show, and
|
|
94
|
+
a pixel-streaming protocol designed for high latency will feel better.
|
|
95
|
+
This is not a react-x11 limitation, it is X11's, and it is the honest place
|
|
96
|
+
to say so.
|
|
97
|
+
|
|
98
|
+
`unverified:` those bands are reasoning from the request/reply counts above,
|
|
99
|
+
not measurements over a shaped link. Measuring them properly means `tc
|
|
100
|
+
netem` between two hosts; if you do it, the numbers belong in this table.
|
|
101
|
+
|
|
102
|
+
## `ssh -X` and `ssh -Y`
|
|
103
|
+
|
|
104
|
+
`-X` runs your app as an **untrusted** X client. `-Y` runs it trusted, which
|
|
105
|
+
turns the restrictions off. They are not "the one that works" and "the one
|
|
106
|
+
that does not":
|
|
107
|
+
|
|
108
|
+
| | `-X` (untrusted) | `-Y` (trusted) |
|
|
109
|
+
| ---------------------------------- | ---------------- | -------------- |
|
|
110
|
+
| read other windows' pixels | no | yes |
|
|
111
|
+
| grab the keyboard, record input | no | yes |
|
|
112
|
+
| take the clipboard from other apps | restricted | yes |
|
|
113
|
+
| your app's own windows | fine | fine |
|
|
114
|
+
|
|
115
|
+
**Prefer `-X`.** react-x11 should work under it — it draws into windows it
|
|
116
|
+
created, which is what untrusted mode is for. If something does not, that is
|
|
117
|
+
a bug worth filing rather than a reason to reach for `-Y`. Most toolkits'
|
|
118
|
+
documentation says "use `-Y` if `-X` misbehaves"; that advice hands every
|
|
119
|
+
program on the remote host the ability to read your screen and log your
|
|
120
|
+
keystrokes.
|
|
121
|
+
|
|
122
|
+
### The failure that looks like a crash
|
|
123
|
+
|
|
124
|
+
An untrusted `-X` cookie **expires**. OpenSSH's `ForwardX11Timeout` defaults
|
|
125
|
+
to 20 minutes; after that the connection is refused and your app dies
|
|
126
|
+
mid-session with a connection reset — no warning, no message that mentions
|
|
127
|
+
X11, and it happens long enough after startup that nobody connects the two.
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
# in ~/.ssh/config
|
|
131
|
+
Host buildbox
|
|
132
|
+
ForwardX11 yes
|
|
133
|
+
ForwardX11Trusted no
|
|
134
|
+
ForwardX11Timeout 8h # or 0 for no expiry
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
If a long-running app dies after roughly twenty minutes and
|
|
138
|
+
`onDisconnect(reason)` reports `'error'`, this is the first thing to check.
|
|
139
|
+
react-x11 tells you it happened, and nothing more —
|
|
140
|
+
[a reconnect is not a reconnect](README.md):
|
|
141
|
+
every window id, pixmap, glyph set and font died with the connection.
|
|
142
|
+
|
|
143
|
+
## `DISPLAY=host:0` — and why not
|
|
144
|
+
|
|
145
|
+
The other way to reach a remote display is to point `DISPLAY` straight at it
|
|
146
|
+
and open port 6000. Do not:
|
|
147
|
+
|
|
148
|
+
- The connection is **plaintext**. Every keystroke, every glyph you draw and
|
|
149
|
+
the authentication cookie itself cross the network in the clear.
|
|
150
|
+
- Making it work usually means `xhost +`, which turns off access control for
|
|
151
|
+
_everyone_, or `xhost +host`, which trusts every user on that host.
|
|
152
|
+
- Most X servers ship with TCP listening disabled now, so it takes
|
|
153
|
+
deliberate work to open the hole.
|
|
154
|
+
|
|
155
|
+
`ssh -X` gives you the same thing, encrypted, authenticated per-session, and
|
|
156
|
+
with no listening port. Use it. If you genuinely need a raw display
|
|
157
|
+
connection — a lab network, a kiosk — carry the cookie explicitly with
|
|
158
|
+
`xauth` and never with `xhost` (see [security.md](security.md)).
|
|
159
|
+
|
|
160
|
+
## The other X servers
|
|
161
|
+
|
|
162
|
+
"An X server" is broader than "a Linux desktop":
|
|
163
|
+
|
|
164
|
+
| where | what runs |
|
|
165
|
+
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
166
|
+
| Linux desktop | X.Org, or **Xwayland** — see below |
|
|
167
|
+
| macOS | [XQuartz](https://www.xquartz.org). `ssh -X` from Terminal starts it for you |
|
|
168
|
+
| Windows | VcXsrv, Xming, or an X server inside WSLg |
|
|
169
|
+
| headless CI | `Xvfb :99 -screen 0 1280x800x24` |
|
|
170
|
+
| a disposable screen | `Xephyr :10 -screen 1200x800` — a window on your desktop that _is_ a display. What the [window-manager example](../examples/README.md) needs |
|
|
171
|
+
| a session you can reattach to | `Xvnc` / TigerVNC, or **Xpra**, which is X11-native and can detach and reattach a running app |
|
|
172
|
+
| no display at all | node-x11's in-process server — [testing.md](testing.md) |
|
|
173
|
+
|
|
174
|
+
**Xwayland: yes.** On a Wayland desktop, X11 clients run under Xwayland, and
|
|
175
|
+
react-x11 is an ordinary X11 client there. Everything in this documentation
|
|
176
|
+
applies. Some window-manager interactions are thinner (Xwayland brokers what
|
|
177
|
+
the compositor is willing to expose), and `WM_TRANSIENT_FOR` and the
|
|
178
|
+
`_NET_WM_STATE` work are among the things that survive the trip — see
|
|
179
|
+
[elements.md](elements.md#transientfor--a-window-that-belongs-to-another).
|
|
180
|
+
|
|
181
|
+
**Native Wayland: not remotely**, and the reason is not effort. Wayland has
|
|
182
|
+
no drawing protocol. A Wayland client allocates a buffer, renders pixels into
|
|
183
|
+
it _itself_, and hands the compositor a handle — there is nothing to send over
|
|
184
|
+
a wire, and no server-side rendering to ask for. The entire premise of this
|
|
185
|
+
page, that drawing crosses the connection, does not exist there. The Wayland
|
|
186
|
+
backend (`backend: 'wayland'`, [wayland-backend.md](wayland-backend.md)) is
|
|
187
|
+
therefore a different program under the same API — it rasterizes locally —
|
|
188
|
+
which is why it is opt-in only and never what `'auto'` picks: on Linux the
|
|
189
|
+
default stays X11, and with it everything on this page.
|
|
190
|
+
|
|
191
|
+
**XQuartz notes**, since it is the most common macOS path:
|
|
192
|
+
|
|
193
|
+
- The window manager is quartz-wm, which has the thinnest EWMH support in
|
|
194
|
+
the stack. It does not advertise `_NET_WM_STATE_ABOVE`, so `alwaysOnTop`
|
|
195
|
+
falls back to Apple-WM window levels ([elements.md](elements.md)).
|
|
196
|
+
- Put `/opt/X11/bin` first on `PATH`. Font family resolution shells out to
|
|
197
|
+
`fc-match`, and Homebrew's fontconfig — which is usually found first —
|
|
198
|
+
ships no macOS system-font aliases, so `sans-serif` resolves to a CJK
|
|
199
|
+
face. Issue [#86](https://github.com/sidorares/react-x11/issues/86).
|
|
200
|
+
|
|
201
|
+
## Making it feel better
|
|
202
|
+
|
|
203
|
+
Everything the renderer does to be cheap locally pays double here, because
|
|
204
|
+
each saved request is a saved trip across the link:
|
|
205
|
+
|
|
206
|
+
- Keep repaints **bounded**. `REACT_X11_DEBUG_PAINT=full` warns, with a
|
|
207
|
+
reason and a stack, on any silent full-window repaint —
|
|
208
|
+
[debugging.md](debugging.md). A full repaint is the difference between
|
|
209
|
+
4 kB and 40 on every frame.
|
|
210
|
+
- `REACT_X11_TRACE=summary` prints the request and byte counts for a live
|
|
211
|
+
app, so you can see what a given interaction actually costs on the wire
|
|
212
|
+
before blaming the link.
|
|
213
|
+
- `ssh -C` compresses. X11 traffic is highly compressible — repeated
|
|
214
|
+
drawing requests, text, glyph data — and on a slow link it is usually
|
|
215
|
+
worth it.
|
|
216
|
+
- Prefer one long-lived connection to many short ones: connection setup is
|
|
217
|
+
several round trips of its own, plus the font and atom warm-up.
|
|
218
|
+
- **Drags across applications are round-trip heavy**, because finding the
|
|
219
|
+
foreign window under the pointer means asking the server, per pointer
|
|
220
|
+
position. Dragging _within_ the app costs nothing extra — it never
|
|
221
|
+
touches the wire — and a drop target that answers with `e.freeze()` stops
|
|
222
|
+
the source asking while the pointer stays inside it. See
|
|
223
|
+
[drag-and-drop.md](drag-and-drop.md).
|
package/docs/scale.md
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# Display scale
|
|
2
|
+
|
|
3
|
+
Every length in react-x11 is a **logical pixel**: a unit sized so that
|
|
4
|
+
`fontSize: 14` is comfortably readable at the distance the display is
|
|
5
|
+
actually viewed from. On the panels of the last decade one logical pixel is
|
|
6
|
+
two device pixels — or 1.5, or 1.25 — and the renderer multiplies exactly
|
|
7
|
+
once, on the way to the server. An app says nothing and gets a UI the same
|
|
8
|
+
physical size on a 2013 monitor and a retina laptop.
|
|
9
|
+
|
|
10
|
+
```jsx
|
|
11
|
+
const root = await createRoot(); // scale: 'auto' is the default
|
|
12
|
+
const root = await createRoot({ scale: 2 }); // pin it
|
|
13
|
+
REACT_X11_SCALE=1.5 node app.js // the user outranks everybody
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```jsx
|
|
17
|
+
import { useScale } from 'react-x11';
|
|
18
|
+
const scale = useScale(); // 1, 2, 1.5... static for the life of the root
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What is logical and what is device
|
|
22
|
+
|
|
23
|
+
**Logical — everything application code touches:**
|
|
24
|
+
|
|
25
|
+
- every style length (`width`, `padding`, `fontSize`, `borderRadius`, the
|
|
26
|
+
lengths inside `boxShadow` and gradient stops, `hitSlop`…)
|
|
27
|
+
- window geometry props (`width`, `height`, `x`, `y`, `minWidth`…) and the
|
|
28
|
+
`onResize` payload
|
|
29
|
+
- event coordinates: `ev.x`/`ev.y`/`ev.localX`/`ev.localY`, wheel
|
|
30
|
+
`deltaX`/`deltaY`, drag `screenX`/`screenY`
|
|
31
|
+
- `getClientRects()`, `measure()`, `useScreens()` rects,
|
|
32
|
+
`anchorRect`/`centerRect`/`anchorArea`
|
|
33
|
+
- the scroll surface: `scrollTo()`/`scrollBy()` arguments, `onScroll` and
|
|
34
|
+
`onViewport` payloads
|
|
35
|
+
- the theme: `DefaultTheme.fontSize` is 14 and stays 14
|
|
36
|
+
|
|
37
|
+
**Device — the renderer's internals and the deliberate escape hatches:**
|
|
38
|
+
|
|
39
|
+
- `node.abs`, yoga's computed layout, everything painted
|
|
40
|
+
- `node.scrollX`/`scrollY`/`contentWidth`/`contentHeight` read directly off
|
|
41
|
+
a ref (positions in the rendered surface; use the `onScroll` payload for
|
|
42
|
+
logical math)
|
|
43
|
+
- `<canvas onDraw>`: the browser's own canvas contract — the box arrives in
|
|
44
|
+
device pixels and the payload carries `scale`, so detail is drawn on the
|
|
45
|
+
real grid. Deliberately **not** a `ctx.scale()`: ntk positions glyphs
|
|
46
|
+
through the transform but sizes them from the font, so a scaled transform
|
|
47
|
+
would move an app's `fillText` without growing it — and it would knock
|
|
48
|
+
every fill off ntk's server-side fast paths besides.
|
|
49
|
+
- a registered element's `paint()` and `hitTest()` (see
|
|
50
|
+
[extending.md](extending.md)): `this.style` and `this.abs` arrive already
|
|
51
|
+
device, and so are `paintDamage()`, the rects handed to `invalidate` and
|
|
52
|
+
`scrollContents` and the rects of an a11y scene; synthetic events are
|
|
53
|
+
logical, and `this.scale` is on every node for the constants that never
|
|
54
|
+
pass through a style and the event coordinate on its way to `abs`.
|
|
55
|
+
|
|
56
|
+
Fonts are the point of the whole exercise: a `fontSize: 14` at scale 2 is
|
|
57
|
+
shaped, measured and rasterized at 28px. Text on a retina panel is _sharper_,
|
|
58
|
+
not bigger-blurrier — the same reason every native toolkit renders this way
|
|
59
|
+
instead of transform-scaling a 1x picture.
|
|
60
|
+
|
|
61
|
+
"Logical" is per node rather than per window as soon as a `scale` prop is in
|
|
62
|
+
the tree: everything in the first list is divided by the scale of the node
|
|
63
|
+
it is about, which is the display's until an enclosing `scale` says
|
|
64
|
+
otherwise. See "A subtree of its own" below.
|
|
65
|
+
|
|
66
|
+
## How `'auto'` decides
|
|
67
|
+
|
|
68
|
+
X11 never grew a scale protocol, so the answer is scattered across four
|
|
69
|
+
generations of convention. The ladder consults each rung only when the ones
|
|
70
|
+
above said nothing, ordered by "who is closest to a human having decided":
|
|
71
|
+
|
|
72
|
+
1. **Environment** — `REACT_X11_SCALE` (ours, wins over everything including
|
|
73
|
+
an explicit `scale:` number — it is the accessibility escape hatch), then
|
|
74
|
+
`GDK_SCALE`, then `QT_SCALE_FACTOR`. The user already told their other
|
|
75
|
+
toolkits; an app of ours on the same desktop should agree.
|
|
76
|
+
2. **XSETTINGS** — `Gdk/WindowScalingFactor` when it is 2+, then `Xft/DPI`
|
|
77
|
+
(in 1024ths of a dpi on the wire; a daemon writing plain dpi is read
|
|
78
|
+
right too). This is what the desktop's own settings dialog writes and
|
|
79
|
+
what every GTK app on the screen is already obeying.
|
|
80
|
+
3. **`RESOURCE_MANAGER`** — `Xft.dpi`, the `xrdb` convention winit,
|
|
81
|
+
Chromium and every terminal emulator read.
|
|
82
|
+
4. **RandR millimetres** — the panel's physical size against its pixel
|
|
83
|
+
size, run per output, under mutter's viewing-distance model: the DPI
|
|
84
|
+
that counts as "1x" is 135 under a 20″ diagonal (a panel in your lap at
|
|
85
|
+
~50cm) and 110 over it (across a desk). Snapped to quarter steps in
|
|
86
|
+
[1, 3]. For calibration: a 27″ 2560×1440 desk monitor computes 109dpi →
|
|
87
|
+
1x; a 27″ 4K computes 163dpi → 1.5x; a 16″ MacBook panel computes
|
|
88
|
+
255dpi → 2x, matching macOS.
|
|
89
|
+
5. **The resolution class** — when the millimetres are absent or caught
|
|
90
|
+
lying, the pixel grid itself is the last signal: nothing ships as a 1x
|
|
91
|
+
panel with a grid ≥3000 wide or taller than 1600 on its short side, so
|
|
92
|
+
those are called 2x and everything else 1x. That takes in the 2560×1664
|
|
93
|
+
and 2880×1800 lids, and a VM window over any retina panel (a UTM window
|
|
94
|
+
on a 14″ MacBook is 2488×1668). Only the confident call is made without
|
|
95
|
+
physical data — 2560×1440 and 2560×1600 stay 1x on purpose, because
|
|
96
|
+
they are the commonest 1x desk monitors there are and only millimetres
|
|
97
|
+
could tell them from a 13″ retina lid.
|
|
98
|
+
6. **1.**
|
|
99
|
+
|
|
100
|
+
Two readings deserve their asterisks. **96 is not an answer**: `Xft.dpi: 96`
|
|
101
|
+
and `Gdk/WindowScalingFactor: 1` are what daemons publish when nobody ever
|
|
102
|
+
opened the dialog (xfsettingsd writes both unconditionally), so they fall
|
|
103
|
+
through to the hardware instead of pinning an unconfigured 4K laptop to
|
|
104
|
+
microscopic. And **millimetres are audited before they are believed**
|
|
105
|
+
(`classifyMm`): zeroes, aspect-ratios-as-sizes (16×9 "millimetres"),
|
|
106
|
+
physical/pixel aspect disagreement, and — the one that motivated all of
|
|
107
|
+
this — virtual machines, detected by EDID vendor and model (`RHT` / "QEMU
|
|
108
|
+
Monitor", VMware, VirtualBox, Parallels, Hyper-V) and by connector name
|
|
109
|
+
(`Virtual-*`, `qxl`, …). `XWAYLAND*` outputs skip the hardware rung too:
|
|
110
|
+
the compositor owns scaling there and publishes its decision through rung
|
|
111
|
+
2, in both of its Xwayland modes.
|
|
112
|
+
|
|
113
|
+
### Servers that are not describing hardware
|
|
114
|
+
|
|
115
|
+
Rungs 4–5 read a panel, so they are skipped where the connection has no
|
|
116
|
+
panel to describe. Two cases, both of them servers people really run:
|
|
117
|
+
|
|
118
|
+
- **XQuartz**, detected by the `Apple-WM` extension. macOS composes the
|
|
119
|
+
desktop in _points_ and hands X the point space, already normalised for
|
|
120
|
+
density: a 16″ retina lid arrives as 1728×1080, a 1x monitor beside it as
|
|
121
|
+
2560×1403, and the window server scales what it is given onto whichever
|
|
122
|
+
panel the window lands on. Inferring a factor here doubles a size macOS
|
|
123
|
+
is about to double again, so the ladder answers 1x with the source
|
|
124
|
+
`xquartz` — the same exemption `XWAYLAND` gets, for the same reason.
|
|
125
|
+
- **A single synthetic output covering every monitor.** XQuartz, Xvfb,
|
|
126
|
+
x11vnc and TigerVNC, Xephyr and pre-RandR drivers answer the output walk
|
|
127
|
+
with one output — usually named `default`, always without millimetres —
|
|
128
|
+
whose CRTC is the _union_ of the desktop. Two 2560×1440 monitors union to
|
|
129
|
+
5120×1440, which rung 5 reads as a retina panel; the machine this was
|
|
130
|
+
found on unioned a retina lid and two 1x monitors into 5120×2520 and drew
|
|
131
|
+
every widget at double size. Xinerama reports those heads separately, so
|
|
132
|
+
more heads than outputs retires rung 5 (`isUnionOutput`). Rung 4 survives
|
|
133
|
+
it — credible millimetres describe real glass however the pixels were
|
|
134
|
+
divided up, which is what an `xrandr --setmonitor` split of one ultrawide
|
|
135
|
+
is.
|
|
136
|
+
|
|
137
|
+
Both guards sit _below_ the configured rungs: `REACT_X11_SCALE`,
|
|
138
|
+
`GDK_SCALE` and a desktop's own XSETTINGS still win, because a person who
|
|
139
|
+
typed a factor outranks the platform.
|
|
140
|
+
|
|
141
|
+
### The machine this was built against
|
|
142
|
+
|
|
143
|
+
A 16″ MacBook panel handed 1:1 to a UTM Linux guest (XFCE, X11) defeats
|
|
144
|
+
rungs 1–4 simultaneously, which is why the ladder has five:
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
core screen 3456×2168 px, "914×573mm" → synthesized to exactly 96dpi
|
|
148
|
+
XSETTINGS Gdk/WindowScalingFactor 1, Xft/DPI 98304 (= 96·1024)
|
|
149
|
+
RESOURCE_MANAGER Xft.dpi: 96 → the never-configured default
|
|
150
|
+
RandR Virtual-1 "870×550mm" → a 40″ panel that is 16″
|
|
151
|
+
EDID vendor RHT, model "QEMU Monitor" → the lie, signed
|
|
152
|
+
verdict 2x via the resolution class → correct
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Every millimetre in that transcript is invented — QEMU fabricates a size
|
|
156
|
+
that makes the maths land on ~100dpi — and every configured source is an
|
|
157
|
+
untouched default. Run `node scripts/scale-probe.mjs` against any display
|
|
158
|
+
to see the same table for it, or `REACT_X11_DEBUG_SCALE=1` in an app to
|
|
159
|
+
watch the ladder resolve.
|
|
160
|
+
|
|
161
|
+
## On Wayland
|
|
162
|
+
|
|
163
|
+
A compositor has a scale protocol, and its answer is the display scale:
|
|
164
|
+
fractional-scale-v1 where it is offered (1.25, 1.5…), else
|
|
165
|
+
`wl_surface.preferred_buffer_scale`, else the integer `wl_output.scale` of
|
|
166
|
+
the output a window is on. Buffers are drawn at that density, so text is
|
|
167
|
+
as sharp at 1.5 as at 2. Two things still outrank it, as on X11:
|
|
168
|
+
|
|
169
|
+
- **`REACT_X11_SCALE`, then `createRoot({ scale })`** pin the factor,
|
|
170
|
+
absolutely: device (buffer) pixels per logical pixel. On a compositor at
|
|
171
|
+
1x, `REACT_X11_SCALE=2` is a window twice the size drawn at full
|
|
172
|
+
resolution, not a small one stretched.
|
|
173
|
+
- **The resolution class, when the compositor says 1.** A VM window over a
|
|
174
|
+
retina panel is the case: QEMU's EDID describes the guest's screen as a
|
|
175
|
+
~100dpi monitor, mutter believes it and runs at 100%, and every app laid
|
|
176
|
+
out at the compositor's word comes up half the size it is on the host.
|
|
177
|
+
When every output is at 1x and the largest has no credible millimetres
|
|
178
|
+
(`classifyMm`) but a retina-class grid, the layout is 2x — what the X11
|
|
179
|
+
ladder answers for the same screen through Xwayland. A compositor that
|
|
180
|
+
scales any output has decided for itself (133% on that same VM is
|
|
181
|
+
someone's choice), and credible millimetres were the compositor's own
|
|
182
|
+
evidence, so both keep their answer.
|
|
183
|
+
|
|
184
|
+
The frame a window draws for itself follows the compositor, not the
|
|
185
|
+
layout: it is drawn at the desktop's scale, beside every other window's.
|
|
186
|
+
`REACT_X11_DEBUG_SCALE=1` prints which of these answered.
|
|
187
|
+
|
|
188
|
+
## Several monitors
|
|
189
|
+
|
|
190
|
+
Rungs 4–5 are computed for every connected output, because a desktop with a
|
|
191
|
+
retina lid and an office monitor genuinely has two answers. The **root's**
|
|
192
|
+
scale — the one layout and paint use — is the primary output's (the largest,
|
|
193
|
+
where nothing is flagged primary), matching what GNOME does on X11: one
|
|
194
|
+
scale for the session, chosen for the display you called primary.
|
|
195
|
+
|
|
196
|
+
The per-output answers ride on `useScreens()` — every entry carries its own
|
|
197
|
+
`scale` — so an app that places windows can do better, and a window can be
|
|
198
|
+
pinned with `createRoot({ scale })` per root. What this deliberately does
|
|
199
|
+
not do is re-scale a window as it is dragged between mismatched monitors:
|
|
200
|
+
X11 has one coordinate space and no per-window scale protocol, so that move
|
|
201
|
+
is a resize the window manager fights mid-drag; Qt is the one toolkit that
|
|
202
|
+
tries it, and "chosen at creation, static after" is the behaviour of
|
|
203
|
+
everything else on this window system.
|
|
204
|
+
|
|
205
|
+
Static-at-startup is the other honest limitation: a mid-session change of
|
|
206
|
+
`Xft.dpi` or a monitor swap does not re-scale running windows. Rare enough
|
|
207
|
+
that GTK3 apps mostly share it; restart the app.
|
|
208
|
+
|
|
209
|
+
## A subtree of its own
|
|
210
|
+
|
|
211
|
+
The display scale is one number for the whole window. `scale` on an element
|
|
212
|
+
is a second factor for one **subtree** of it:
|
|
213
|
+
|
|
214
|
+
```jsx
|
|
215
|
+
<box scale={zoom} style={{ width: w / zoom, height: h / zoom }}>
|
|
216
|
+
<NodeBody /> // written in plain logical pixels, unaware of the zoom
|
|
217
|
+
</box>
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The semantics are CSS's `zoom`, not a transform:
|
|
221
|
+
|
|
222
|
+
- A node's **effective scale** is its parent's times its own `scale` prop,
|
|
223
|
+
so nested props multiply and the display scale is the floor they multiply.
|
|
224
|
+
`node.scale` is that number, and every path that already asked for it —
|
|
225
|
+
`scaleResolvedStyle` at the end of the style funnel, the caret and
|
|
226
|
+
scrollbar constants, shadows and gradients, a registered element's own
|
|
227
|
+
`paint()` — follows with no second mechanism.
|
|
228
|
+
- **The node's own style scales too.** `scale={2}` on a box with
|
|
229
|
+
`padding: 8` gets 16 device pixels of padding, not 8 with bigger children
|
|
230
|
+
inside it. That is what makes the caller's job the one-liner above: divide
|
|
231
|
+
the size you want the box to occupy by the zoom, and write everything
|
|
232
|
+
inside it at 1x.
|
|
233
|
+
- **Text is shaped at the size it is drawn at.** A `fontSize: 14` inside
|
|
234
|
+
`scale={2}` is shaped, measured and rasterized at 28 — the same thing the
|
|
235
|
+
display scale does, for the same reason. A zoomed-in graph is not a
|
|
236
|
+
stretched screenshot of a zoomed-out one.
|
|
237
|
+
- **Yoga lays out the scaled numbers** like any others, so a zoomed subtree
|
|
238
|
+
wraps, flexes and measures its content floors at the size it is drawn.
|
|
239
|
+
|
|
240
|
+
What it is for: an element that owns a viewport — a graph pane, a canvas, a
|
|
241
|
+
map — and mounts application content inside it. The pane draws its own
|
|
242
|
+
world, and the React subtree beside it has to follow the same zoom;
|
|
243
|
+
`ctx.scale()` cannot do that here (see "Why multiply values instead of
|
|
244
|
+
transforming the context" below) and a layer transform on the Cocoa
|
|
245
|
+
presenter would draw a zoomed body whose hit testing still lived at the
|
|
246
|
+
unzoomed rects.
|
|
247
|
+
|
|
248
|
+
### What a boundary costs
|
|
249
|
+
|
|
250
|
+
**Events are in the target's unit.** `ev.x`, `ev.y`, `localX`/`localY` and
|
|
251
|
+
`getClientRects()` on a node inside a zoomed subtree are all divided by
|
|
252
|
+
_that node's_ scale, so they agree with each other and with the styles it
|
|
253
|
+
was written in — a handler inside the subtree needs to know nothing. The
|
|
254
|
+
trade-off is CSS `zoom`'s: a handler on an **unzoomed ancestor** reads a
|
|
255
|
+
coordinate in the zoomed descendant's unit, because the target is what
|
|
256
|
+
decides. When a pane needs its own coordinates for a hit that landed inside
|
|
257
|
+
the content it hosts, `ev.nativeEvent` is the way back — device pixels of
|
|
258
|
+
the window, which `/ node.scale` converts to whichever unit is wanted.
|
|
259
|
+
`screenX`/`screenY` are unaffected: they are desktop coordinates, and the
|
|
260
|
+
desktop has one scale.
|
|
261
|
+
|
|
262
|
+
A wheel notch scrolls `48` of the target's logical pixels, so content under
|
|
263
|
+
the pointer travels the distance the zoom says it should.
|
|
264
|
+
|
|
265
|
+
**A real X window is its own root.** `<window>` and `<popup>` take no
|
|
266
|
+
`scale` prop and ignore an enclosing one: their geometry is the server's, a
|
|
267
|
+
window manager sees no zoom, and a menu opened from a zoomed card should
|
|
268
|
+
come up at the size the rest of the app's menus are. `<glarea>` and
|
|
269
|
+
`<foreign>` do inherit — their boxes are laid out by the parent like any
|
|
270
|
+
other child's.
|
|
271
|
+
|
|
272
|
+
**Quantise a gesture-driven zoom.** Every distinct factor is a distinct
|
|
273
|
+
font size to shape and cache, so a wheel handler feeding the raw accumulator
|
|
274
|
+
into `scale` makes a new set of glyphs per frame. Round to a step — 5%, or
|
|
275
|
+
a ladder of preset zooms — and the cache does its job.
|
|
276
|
+
|
|
277
|
+
## Prior art, and where this sits
|
|
278
|
+
|
|
279
|
+
| Toolkit | X11 sources, in order | Notes |
|
|
280
|
+
| -------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
281
|
+
| GTK 3/4 | `GDK_SCALE` env; XSETTINGS `Gdk/WindowScalingFactor` | integer only; fonts via `Xft/DPI` |
|
|
282
|
+
| Qt 5/6 | `QT_SCALE_FACTOR`, `QT_SCREEN_SCALE_FACTORS`; per-screen logical DPI (`Xft.dpi`), physical DPI from RandR | the only per-screen attempt on X11 |
|
|
283
|
+
| winit | `WINIT_X11_SCALE_FACTOR`; `Xft.dpi`; RandR mm | mm path famously burned by 1×1mm and VM EDIDs ([winit#1983](https://github.com/rust-windowing/winit/issues/1983)) |
|
|
284
|
+
| Chromium/Electron | `Xft.dpi` via XSETTINGS then xrdb; `GDK_SCALE` | never trusts RandR mm |
|
|
285
|
+
| SDL 2/3 | `Xft.dpi` only | 1x on every unconfigured desktop |
|
|
286
|
+
| mutter (the config UI's default) | per-monitor mm under the 135/110-dpi viewing-distance model | the model rung 4 adopts, constants and all |
|
|
287
|
+
|
|
288
|
+
The composition is the contribution: desktop configuration first (rungs
|
|
289
|
+
1–3, which is where Chromium/SDL stop — correct on configured desktops,
|
|
290
|
+
tiny on unconfigured HiDPI ones), mutter's perceptual model where the
|
|
291
|
+
hardware is honest (which is where winit's mm math stops — right until an
|
|
292
|
+
EDID lies), and the resolution class where it is not (which nobody else
|
|
293
|
+
has, and which is the only rung that survives a VM).
|
|
294
|
+
|
|
295
|
+
## Design notes
|
|
296
|
+
|
|
297
|
+
**Why multiply values instead of transforming the context.** Two hard
|
|
298
|
+
reasons, both ntk's. Its 2d context batches solid fills into server-side
|
|
299
|
+
`Render.FillRectangles` and takes similar fast paths for strokes, rounded
|
|
300
|
+
rects and glyph runs — all gated on an identity transform, so a frame-wide
|
|
301
|
+
`ctx.scale(2,2)` would rasterize every box in the UI as polygons. And its
|
|
302
|
+
text model sizes glyphs from the font while only _positioning_ them through
|
|
303
|
+
the transform, so scaled-transform text comes out moved but not grown.
|
|
304
|
+
Multiplying at the style funnel instead means paint code, damage rects, the
|
|
305
|
+
scroll blit and the paint cache all keep working in integer device pixels
|
|
306
|
+
with the transforms they always had. (`paintcache.js` even guards against
|
|
307
|
+
the other choice: a non-identity CTM disables it entirely.)
|
|
308
|
+
|
|
309
|
+
**Where the multiply lives.** One place for styles —
|
|
310
|
+
`scaleResolvedStyle()`, at the end of the style funnel in `_syncStyle`, so
|
|
311
|
+
state blocks, size queries and the flex shorthand all still think in the
|
|
312
|
+
logical pixels the app wrote. One place for the theme's font size — the
|
|
313
|
+
root branch of `inheritedTextStyle`, because descendants inherit an
|
|
314
|
+
already-resolved size and any second multiply would compound. One place for
|
|
315
|
+
window geometry (`scaleWindowGeometry`), one per string-parsed decoration
|
|
316
|
+
(`shadowSpecs`/`gradientSpec`, whose parses are memoized on the raw string
|
|
317
|
+
so the scale rides into the memo key), and division at each app-facing
|
|
318
|
+
egress (`SyntheticEvent`, `getClientRects`, `onScroll`, `useScreens`…).
|
|
319
|
+
`yoga` needs no `pointScaleFactor` games: layout runs directly in device
|
|
320
|
+
pixels and its ordinary whole-pixel rounding is what keeps a 1-logical-px
|
|
321
|
+
border crisp at 1.5x.
|
|
322
|
+
|
|
323
|
+
**Fractional scales** work end to end (`1.25`, `1.5`, quarter steps from
|
|
324
|
+
the ladder), with the usual X11 caveat: a 1-logical-px hairline at 1.25x is
|
|
325
|
+
1 or 2 device pixels depending on where it lands. Integer scales have no
|
|
326
|
+
such seams.
|
|
327
|
+
|
|
328
|
+
**What tests see.** The headless harnesses resolve to exactly 1 — a mock
|
|
329
|
+
app has no display to ask — so every geometry assertion in the suite means
|
|
330
|
+
its numbers literally. `createRoot({ app, scale: 2 })` opts a test in, and
|
|
331
|
+
so does `renderX11(element, { scale: 2 })` from `react-x11/test`;
|
|
332
|
+
`test/scale-render.test.js` is the boundary contract pinned as tests.
|
|
333
|
+
`setScaleForTests()` (from `src/scale.js`) pins a factor without a
|
|
334
|
+
connection.
|
|
335
|
+
|
|
336
|
+
**Known costs.** The paint cache's per-item budget is device pixels, so at
|
|
337
|
+
2x a widget occupies 4× the budget it did — icons and checkboxes still fit
|
|
338
|
+
comfortably. Damage rects, protocol traffic and backing stores scale the
|
|
339
|
+
same way any HiDPI surface does.
|