react-x11 2.0.0 → 2.1.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/package.json +4 -3
- package/src/components/Checkbox.js +24 -11
- package/src/components/Radio.js +20 -6
- package/src/components/theme.js +29 -0
- package/src/events.js +41 -2
- package/src/nodes.js +25 -5
- package/src/scale.js +136 -6
- package/src/screens.js +21 -3
- package/src/windowstate.js +21 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-x11",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "react renderer with X11 as a target",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"files": [
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"examples:foreign": "tsx examples/foreign.jsx",
|
|
50
50
|
"examples:frame": "tsx examples/frame.jsx",
|
|
51
51
|
"labs:text-baseline": "tsx examples/labs/text-baseline.jsx",
|
|
52
|
+
"labs:direct-gl": "tsx examples/labs/direct-gl.jsx",
|
|
52
53
|
"examples:viewer3d": "tsx examples/viewer3d.jsx",
|
|
53
54
|
"examples:transparent": "tsx examples/transparent.jsx",
|
|
54
55
|
"examples:windows": "tsx examples/windows.jsx",
|
|
@@ -82,7 +83,7 @@
|
|
|
82
83
|
"node": ">=20.19"
|
|
83
84
|
},
|
|
84
85
|
"dependencies": {
|
|
85
|
-
"ntk": "^8.
|
|
86
|
+
"ntk": "^8.4.0",
|
|
86
87
|
"react-reconciler": "^0.33.0",
|
|
87
88
|
"yoga-layout": "^3.2.1"
|
|
88
89
|
},
|
|
@@ -90,10 +91,10 @@
|
|
|
90
91
|
"dbus-native": "^0.15.1"
|
|
91
92
|
},
|
|
92
93
|
"peerDependencies": {
|
|
93
|
-
"react": "^19.0.0",
|
|
94
94
|
"@babel/core": "^8.0.0",
|
|
95
95
|
"@babel/plugin-transform-react-jsx": "^8.0.0",
|
|
96
96
|
"hot-module-replacement": "^4.0.1",
|
|
97
|
+
"react": "^19.0.0",
|
|
97
98
|
"react-refresh": "^0.18.0"
|
|
98
99
|
},
|
|
99
100
|
"peerDependenciesMeta": {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import React from 'react';
|
|
6
6
|
import { changeEvent } from './change.js';
|
|
7
7
|
import { Icon } from './Icon.js';
|
|
8
|
-
import { labelContent, useControl, useTheme } from './theme.js';
|
|
8
|
+
import { focusRingStyle, labelContent, useControl, useTheme } from './theme.js';
|
|
9
9
|
|
|
10
10
|
const h = React.createElement;
|
|
11
11
|
|
|
@@ -52,18 +52,28 @@ export function Checkbox({
|
|
|
52
52
|
: hover
|
|
53
53
|
? theme.accentHover
|
|
54
54
|
: theme.accent;
|
|
55
|
-
// An empty well has no fill to step, so it shows
|
|
56
|
-
// different places:
|
|
57
|
-
//
|
|
58
|
-
// cannot be told apart from says nothing about the press
|
|
55
|
+
// An empty well has no fill to step, so it shows its states in two
|
|
56
|
+
// different places: focus is a *colour* on the border, hover and press are
|
|
57
|
+
// a *step* of the inside. Three looks, which is the point — a hover the
|
|
58
|
+
// press cannot be told apart from says nothing about the press — and four
|
|
59
|
+
// with focus, because the two channels compose instead of overwriting.
|
|
60
|
+
//
|
|
61
|
+
// They used to share the border, where hover won, and that made the focus
|
|
62
|
+
// colour unreachable by the gesture that focuses by pointer: a click ends
|
|
63
|
+
// with the pointer sitting on the control it just focused, so the well was
|
|
64
|
+
// hover-grey for as long as the user was looking at it and only turned
|
|
65
|
+
// blue once they moved the mouse away.
|
|
59
66
|
const empty = {
|
|
60
|
-
borderColor:
|
|
61
|
-
|
|
67
|
+
borderColor: focused
|
|
68
|
+
? theme.borderFocus
|
|
69
|
+
: pressed || hover
|
|
62
70
|
? theme.textMuted
|
|
63
|
-
:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
: theme.border,
|
|
72
|
+
backgroundColor: pressed
|
|
73
|
+
? theme.surfaceActive
|
|
74
|
+
: hover
|
|
75
|
+
? theme.surfaceHover
|
|
76
|
+
: theme.surface,
|
|
67
77
|
};
|
|
68
78
|
return h(
|
|
69
79
|
'box',
|
|
@@ -91,6 +101,9 @@ export function Checkbox({
|
|
|
91
101
|
backgroundColor: checked ? fill : empty.backgroundColor,
|
|
92
102
|
alignItems: 'center',
|
|
93
103
|
justifyContent: 'center',
|
|
104
|
+
// a checked well is filled with the accent, so the border tier
|
|
105
|
+
// above has nothing left to say — see {@link focusRingStyle}
|
|
106
|
+
...focusRingStyle(theme, checked && focused),
|
|
94
107
|
},
|
|
95
108
|
},
|
|
96
109
|
checked &&
|
package/src/components/Radio.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import React, { useContext, useEffect, useMemo, useRef } from 'react';
|
|
6
6
|
import { changeEvent } from './change.js';
|
|
7
|
-
import { labelContent, useControl, useTheme } from './theme.js';
|
|
7
|
+
import { focusRingStyle, labelContent, useControl, useTheme } from './theme.js';
|
|
8
8
|
import { XK_DOWN, XK_LEFT, XK_RIGHT, XK_UP } from './keys.js';
|
|
9
9
|
|
|
10
10
|
const h = React.createElement;
|
|
@@ -109,16 +109,30 @@ export function Radio({ value, children, label, disabled = false }) {
|
|
|
109
109
|
height: 16,
|
|
110
110
|
borderRadius: 8,
|
|
111
111
|
borderWidth: theme.borderWidth,
|
|
112
|
+
// Focus on the border, hover and press on the inside — the two
|
|
113
|
+
// channels Checkbox splits them into, and for the same reason: a
|
|
114
|
+
// click leaves the pointer on the control, so a focus colour hover
|
|
115
|
+
// can overwrite is one the pointer user never gets to see.
|
|
116
|
+
//
|
|
117
|
+
// A *selected* radio has spent its border on the fill, and the
|
|
118
|
+
// branch below it was unreachable — the one radio in a group that
|
|
119
|
+
// can be clicked without changing anything was also the only one
|
|
120
|
+
// with nothing to show for it. It gets the ring instead.
|
|
112
121
|
borderColor: selected
|
|
113
122
|
? fill
|
|
114
|
-
:
|
|
115
|
-
? theme.
|
|
116
|
-
:
|
|
117
|
-
? theme.
|
|
123
|
+
: focused
|
|
124
|
+
? theme.borderFocus
|
|
125
|
+
: pressed || hover
|
|
126
|
+
? theme.textMuted
|
|
118
127
|
: theme.border,
|
|
119
|
-
backgroundColor: pressed
|
|
128
|
+
backgroundColor: pressed
|
|
129
|
+
? theme.surfaceActive
|
|
130
|
+
: hover
|
|
131
|
+
? theme.surfaceHover
|
|
132
|
+
: theme.surface,
|
|
120
133
|
alignItems: 'center',
|
|
121
134
|
justifyContent: 'center',
|
|
135
|
+
...focusRingStyle(theme, selected && focused),
|
|
122
136
|
},
|
|
123
137
|
},
|
|
124
138
|
selected &&
|
package/src/components/theme.js
CHANGED
|
@@ -334,6 +334,35 @@ export const capTrim = Object.freeze({ textBoxTrim: 'cap-alphabetic' });
|
|
|
334
334
|
*/
|
|
335
335
|
export const capBand = (fontSize) => Math.round(fontSize * 0.72);
|
|
336
336
|
|
|
337
|
+
/**
|
|
338
|
+
* The focus ring, as style, for the one part of a widget that has to draw
|
|
339
|
+
* its own — or `null`, to be spread away, when it does not.
|
|
340
|
+
*
|
|
341
|
+
* Every empty control says focus in its border (`borderFocus`), which is the
|
|
342
|
+
* `:focus` tier styling.md asks for: a colour change welcome however focus
|
|
343
|
+
* arrived. A **filled** one — a checked box, a selected radio — has spent
|
|
344
|
+
* that border on the fill and has no colour left to spend, so it says the
|
|
345
|
+
* same thing one ring further out instead.
|
|
346
|
+
*
|
|
347
|
+
* On the part, not the row. The row already draws core's ring, but that one
|
|
348
|
+
* is the *keyboard* tier — `:focus-visible`, so it arrives on Tab and not on
|
|
349
|
+
* a click — and it wraps the label with the well. This is the other tier,
|
|
350
|
+
* and the two stack the same way the border colour and that ring already do.
|
|
351
|
+
*
|
|
352
|
+
* `undefined` for the width when off, never `0`: a node with no
|
|
353
|
+
* `outlineWidth` of its own is one core can leave out of the widened damage
|
|
354
|
+
* rects entirely (`_outlineExtent`, src/nodes.js), and `0` is the documented
|
|
355
|
+
* way to opt *out* of a ring the node would otherwise get.
|
|
356
|
+
*/
|
|
357
|
+
export const focusRingStyle = (theme, on) =>
|
|
358
|
+
on
|
|
359
|
+
? {
|
|
360
|
+
outlineWidth: theme.focusRingWidth,
|
|
361
|
+
outlineColor: theme.focusRing,
|
|
362
|
+
outlineOffset: theme.focusRingOffset,
|
|
363
|
+
}
|
|
364
|
+
: null;
|
|
365
|
+
|
|
337
366
|
/**
|
|
338
367
|
* The geometry of rows on a rounded sheet: how far a row sits inside the
|
|
339
368
|
* sheet's edge, and how round its own corners are.
|
package/src/events.js
CHANGED
|
@@ -13,7 +13,12 @@ import { callHandler, reportHandlerError } from './errors.js';
|
|
|
13
13
|
import { armDrag } from './dnd.js';
|
|
14
14
|
import { desktopSettings } from './desktopsettings.js';
|
|
15
15
|
import { noteInputTime } from './inputtime.js';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
hooks as a11yHooks,
|
|
18
|
+
isFocusable,
|
|
19
|
+
isTextControl,
|
|
20
|
+
effectivelyVisible,
|
|
21
|
+
} from './a11y.js';
|
|
17
22
|
import { Composer, composeTableFor } from './compose.js';
|
|
18
23
|
import { acceleratorKeysym } from './keyboard.js';
|
|
19
24
|
import { MOD } from './keysyms.js';
|
|
@@ -340,6 +345,11 @@ export class EventManager {
|
|
|
340
345
|
// release that continue the same gesture follow
|
|
341
346
|
this._downDefaulted = false;
|
|
342
347
|
this.capturedNode = null;
|
|
348
|
+
// The cursor the window is already wearing. `null` rather than
|
|
349
|
+
// undefined on purpose: `null` *is* a cursor — X None, "inherit the
|
|
350
|
+
// parent's" — and it is the one a window starts with, so the first
|
|
351
|
+
// hover over a subtree that names no cursor has nothing to change.
|
|
352
|
+
this._appliedCursor = null;
|
|
343
353
|
// Attention (ntk#37): the one node the pointer looks like it is heading
|
|
344
354
|
// for, the recent pointer samples the trajectory is estimated from, and
|
|
345
355
|
// the window's candidate registry held directly so the motion path costs
|
|
@@ -1497,6 +1507,12 @@ export class EventManager {
|
|
|
1497
1507
|
* handing focus back as it closes, `node.focus()` from an application —
|
|
1498
1508
|
* lights one, because none of those tell the user where focus went.
|
|
1499
1509
|
*
|
|
1510
|
+
* With one exception, which is CSS's too: a **text control** clicked into
|
|
1511
|
+
* lights a ring anyway (`_ringsOnPress`). What the pointer user knows is
|
|
1512
|
+
* where they clicked, not that the next keystroke will land there, and a
|
|
1513
|
+
* clicked field is about to swallow the keyboard — so the one thing the
|
|
1514
|
+
* ring says that the click did not is exactly the thing worth saying.
|
|
1515
|
+
*
|
|
1500
1516
|
* `backwards` is only meaningful for `reason: 'key'`, and only one element
|
|
1501
1517
|
* has ever needed it: XEmbed distinguishes a Tab arriving forwards from a
|
|
1502
1518
|
* back-Tab, because that is what tells an embedded client whether to focus
|
|
@@ -1548,7 +1564,10 @@ export class EventManager {
|
|
|
1548
1564
|
// and back onto the window that owns it.
|
|
1549
1565
|
if (!this.keyboardFocused && !restoring) this.node.window?.focus?.();
|
|
1550
1566
|
node.setStyleState(':focus', true);
|
|
1551
|
-
node.setStyleState(
|
|
1567
|
+
node.setStyleState(
|
|
1568
|
+
':focus-visible',
|
|
1569
|
+
reason !== 'pointer' || this._ringsOnPress(node),
|
|
1570
|
+
);
|
|
1552
1571
|
// …and nothing scrolls to meet it either: the node is coming back to
|
|
1553
1572
|
// the arrangement it left, and this reveal's layout has not run, so a
|
|
1554
1573
|
// scroll here would be computed from a rect that does not exist yet.
|
|
@@ -1564,6 +1583,26 @@ export class EventManager {
|
|
|
1564
1583
|
a11yHooks.focus?.(old, node);
|
|
1565
1584
|
}
|
|
1566
1585
|
|
|
1586
|
+
/**
|
|
1587
|
+
* Whether a press lands `:focus-visible` on this node as well as `:focus`.
|
|
1588
|
+
*
|
|
1589
|
+
* The UA rule browsers settled on, and for the reason they settled on it:
|
|
1590
|
+
* a control the keyboard is about to talk to has to show that it holds the
|
|
1591
|
+
* keyboard, whichever way focus arrived. Clicking into a field puts the
|
|
1592
|
+
* caret somewhere the user chose and then makes every keystroke go there —
|
|
1593
|
+
* a state a button, a checkbox or a tab does not enter on a click, which
|
|
1594
|
+
* is why those still stay dark until Tab.
|
|
1595
|
+
*
|
|
1596
|
+
* Asked as "does this element hold editable text", not as "is its kind
|
|
1597
|
+
* `<textinput>`": `isTextControl` is the same predicate the AT-SPI
|
|
1598
|
+
* EDITABLE state is built on (src/a11y.js), so `<textarea>` and a
|
|
1599
|
+
* third-party editor reporting an editable text state answer yes on their
|
|
1600
|
+
* own — the two routes that make a node take the keyboard cannot drift.
|
|
1601
|
+
*/
|
|
1602
|
+
_ringsOnPress(node) {
|
|
1603
|
+
return isTextControl(node);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1567
1606
|
/**
|
|
1568
1607
|
* Flip `:focus-within` over the focused node's ancestor chain, diffed the
|
|
1569
1608
|
* way hover and the press chain are.
|
package/src/nodes.js
CHANGED
|
@@ -262,6 +262,7 @@ const INVALIDATE_REASONS = new Set([
|
|
|
262
262
|
'props', // a React commit changed what a node draws
|
|
263
263
|
'style-state', // :hover/:focus/:active/:disabled restyle
|
|
264
264
|
'shadow', // a boxShadow got smaller: where it *was* still owes a repaint
|
|
265
|
+
'outline', // …and the same for an outline a style swap took away
|
|
265
266
|
'theme', // a theme/token change restyled a subtree
|
|
266
267
|
'direction', // the reading direction moved: sides, glyph order, bar edge
|
|
267
268
|
'animation', // a transition frame
|
|
@@ -1707,6 +1708,26 @@ export class Node {
|
|
|
1707
1708
|
);
|
|
1708
1709
|
}
|
|
1709
1710
|
}
|
|
1711
|
+
// An outline that just got smaller — or went away — owes the same debt,
|
|
1712
|
+
// and it is only ever owed here. Core's own ring rides `:focus-visible`,
|
|
1713
|
+
// where `EventManager.focus` claims the region while the ring is still
|
|
1714
|
+
// on; what arrives through a style swap is the `outlineWidth` escape
|
|
1715
|
+
// hatch (see `_outline`) — an application outlining a node for a reason
|
|
1716
|
+
// of its own, or a widget ringing one *part* of itself, the way
|
|
1717
|
+
// `<Checkbox>` rings its checked well and drops the ring again on blur.
|
|
1718
|
+
if (
|
|
1719
|
+
displayed.outlineWidth !== this.style.outlineWidth ||
|
|
1720
|
+
displayed.outlineOffset !== this.style.outlineOffset
|
|
1721
|
+
) {
|
|
1722
|
+
const shrank = this._outlineExtent(displayed) - this._outlineExtent();
|
|
1723
|
+
if (shrank > 0) {
|
|
1724
|
+
this.root?.invalidate(
|
|
1725
|
+
false,
|
|
1726
|
+
insetRect(this.paintBounds(), -shrank),
|
|
1727
|
+
'outline',
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1710
1731
|
return this.style;
|
|
1711
1732
|
}
|
|
1712
1733
|
|
|
@@ -3260,8 +3281,7 @@ export class Node {
|
|
|
3260
3281
|
* costs nothing until it is asked for — which is once per focused node
|
|
3261
3282
|
* per frame, not once per node per commit.
|
|
3262
3283
|
*/
|
|
3263
|
-
_outline() {
|
|
3264
|
-
const style = this.style;
|
|
3284
|
+
_outline(style = this.style) {
|
|
3265
3285
|
const explicit = style.outlineWidth !== undefined;
|
|
3266
3286
|
if (!explicit && !this._focusableForRing()) return null;
|
|
3267
3287
|
const theme = this.theme;
|
|
@@ -3287,10 +3307,10 @@ export class Node {
|
|
|
3287
3307
|
* *erases* a ring is the one case where the state has already flipped
|
|
3288
3308
|
* back, and `EventManager.focus` claims the region before it does.
|
|
3289
3309
|
*/
|
|
3290
|
-
_outlineExtent() {
|
|
3291
|
-
if (
|
|
3310
|
+
_outlineExtent(style = this.style) {
|
|
3311
|
+
if (style.outlineWidth === undefined && !this.states[':focus-visible'])
|
|
3292
3312
|
return 0;
|
|
3293
|
-
const outline = this._outline();
|
|
3313
|
+
const outline = this._outline(style);
|
|
3294
3314
|
return outline ? outline.width + Math.max(0, outline.offset) : 0;
|
|
3295
3315
|
}
|
|
3296
3316
|
|
package/src/scale.js
CHANGED
|
@@ -54,6 +54,23 @@
|
|
|
54
54
|
// subtly wrong size everywhere.
|
|
55
55
|
// 6. **1**, the answer X11 shipped with in 1987.
|
|
56
56
|
//
|
|
57
|
+
// Rungs 4 and 5 read hardware, so they are skipped where the connection is
|
|
58
|
+
// not describing hardware, and both cases are servers people really run:
|
|
59
|
+
//
|
|
60
|
+
// * **XQuartz** composes in macOS *points* and hands X the point space —
|
|
61
|
+
// the retina lid arrives as 1728x1080, already density-normalised, and
|
|
62
|
+
// the window server scales it onto the panel afterwards. Inferring a
|
|
63
|
+
// factor here doubles a size macOS is about to double again. Detected
|
|
64
|
+
// by the `Apple-WM` extension, exempted exactly like `XWAYLAND` in
|
|
65
|
+
// `VIRTUAL_OUTPUT_NAME`: the compositor owns scaling, so we do not.
|
|
66
|
+
// * **A single synthetic output** covering every monitor. XQuartz, Xvfb,
|
|
67
|
+
// VNC servers and Xephyr answer the RandR walk with one output named
|
|
68
|
+
// `default` whose CRTC is the *union* of the desktop; two 2560x1440
|
|
69
|
+
// monitors union to 5120x1440, which rung 5 would read as a retina
|
|
70
|
+
// panel. Xinerama reports those heads separately, so more heads than
|
|
71
|
+
// outputs retires rung 5 — see `isUnionOutput`. Rung 4 survives it:
|
|
72
|
+
// millimetres describe real glass however the pixels were split.
|
|
73
|
+
//
|
|
57
74
|
// A machine can defeat every rung above the last two — the one this was
|
|
58
75
|
// written against does: UTM in retina mode hands the guest the MacBook's
|
|
59
76
|
// full 3456x2168 grid, QEMU's EDID invents millimetres that read as 100dpi,
|
|
@@ -269,13 +286,48 @@ export function snapScale(value) {
|
|
|
269
286
|
return Math.min(3, Math.max(1, Math.round(value * 4) / 4));
|
|
270
287
|
}
|
|
271
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Is this RandR output list describing panels, or one synthetic output
|
|
291
|
+
* covering the whole desktop?
|
|
292
|
+
*
|
|
293
|
+
* Servers that never grew a real output model — XQuartz, Xvfb, x11vnc and
|
|
294
|
+
* TigerVNC, Xephyr, old drivers under `xorg.conf` — answer the RandR walk
|
|
295
|
+
* with a single output (usually named `default`, always without
|
|
296
|
+
* millimetres) whose CRTC is the *union* of every monitor attached. On a
|
|
297
|
+
* desktop with two monitors side by side that union is twice as wide as any
|
|
298
|
+
* panel in it, and rung 5 reads pixel counts: a pair of 2560x1440 monitors
|
|
299
|
+
* unions to 5120x1440 and is judged retina-class, which is how a 1x desk
|
|
300
|
+
* ends up drawing everything at double size.
|
|
301
|
+
*
|
|
302
|
+
* Xinerama is the cross-check, and it is free of the same blind spot: the
|
|
303
|
+
* same servers report the heads individually there, because that is the
|
|
304
|
+
* only geometry protocol they implement. More heads than outputs means the
|
|
305
|
+
* output list is not per-panel data, whatever it claims.
|
|
306
|
+
*/
|
|
307
|
+
export function isUnionOutput(outputs, heads) {
|
|
308
|
+
return (
|
|
309
|
+
Array.isArray(outputs) &&
|
|
310
|
+
outputs.length > 0 &&
|
|
311
|
+
Number.isInteger(heads) &&
|
|
312
|
+
outputs.length < heads
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
272
316
|
/**
|
|
273
317
|
* One monitor's metadata → `{ scale, source, reason }`, using only what the
|
|
274
318
|
* connection reported: pixel geometry, claimed millimetres, EDID. This is
|
|
275
319
|
* rungs 4 and 5 of the ladder for one output; the caller stacks the
|
|
276
320
|
* desktop-configuration rungs above it.
|
|
321
|
+
*
|
|
322
|
+
* `perPanel: false` says this record's geometry spans more than one monitor
|
|
323
|
+
* (`isUnionOutput` above), which retires rung 5 alone: the resolution class
|
|
324
|
+
* is a statement about *a panel* and means nothing about a union of them.
|
|
325
|
+
* Rung 4 still answers when it can, because credible millimetres describe
|
|
326
|
+
* real glass however the pixels were divided up afterwards — a `--setmonitor`
|
|
327
|
+
* split of one ultrawide is the case that reaches this branch on a server
|
|
328
|
+
* whose RandR is otherwise perfectly honest.
|
|
277
329
|
*/
|
|
278
|
-
export function monitorScaleFromMetadata(monitor) {
|
|
330
|
+
export function monitorScaleFromMetadata(monitor, { perPanel = true } = {}) {
|
|
279
331
|
const mm = classifyMm(monitor, monitor);
|
|
280
332
|
const pxW = monitor.width ?? 0;
|
|
281
333
|
const pxH = monitor.height ?? 0;
|
|
@@ -306,7 +358,7 @@ export function monitorScaleFromMetadata(monitor) {
|
|
|
306
358
|
// grids stay at 1 on purpose — 2560x1440 is the commonest *1x* desk
|
|
307
359
|
// monitor there is, and only millimetres could tell it from a 13" retina
|
|
308
360
|
// lid, which is exactly the data this branch does not have.
|
|
309
|
-
if (Math.min(pxW, pxH) >= 1800 || Math.max(pxW, pxH) >= 3000) {
|
|
361
|
+
if (perPanel && (Math.min(pxW, pxH) >= 1800 || Math.max(pxW, pxH) >= 3000)) {
|
|
310
362
|
return {
|
|
311
363
|
scale: 2,
|
|
312
364
|
source: 'resolution',
|
|
@@ -316,7 +368,10 @@ export function monitorScaleFromMetadata(monitor) {
|
|
|
316
368
|
return {
|
|
317
369
|
scale: 1,
|
|
318
370
|
source: 'default',
|
|
319
|
-
reason:
|
|
371
|
+
reason: perPanel
|
|
372
|
+
? `no credible density data (mm ${mm}, ${pxW}x${pxH})`
|
|
373
|
+
: `${pxW}x${pxH} spans several monitors, so its pixel count says ` +
|
|
374
|
+
`nothing about any panel (mm ${mm})`,
|
|
320
375
|
};
|
|
321
376
|
}
|
|
322
377
|
|
|
@@ -468,6 +523,57 @@ async function readOutputs(app) {
|
|
|
468
523
|
return connected.length ? connected : null;
|
|
469
524
|
}
|
|
470
525
|
|
|
526
|
+
/**
|
|
527
|
+
* How many monitors Xinerama says are attached, or null where the server
|
|
528
|
+
* does not answer. Read only to sanity-check the RandR walk against
|
|
529
|
+
* (`isUnionOutput`) — the geometry itself is `screens.js`'s business.
|
|
530
|
+
*
|
|
531
|
+
* A server with the extension present but inactive replies with one screen
|
|
532
|
+
* covering everything, which is the same answer as "one monitor" and is
|
|
533
|
+
* treated as such: the cross-check needs a *disagreement* to fire.
|
|
534
|
+
*/
|
|
535
|
+
async function countHeads(app) {
|
|
536
|
+
const xinerama = await requireExtension(app, 'xinerama');
|
|
537
|
+
if (!xinerama?.QueryScreens) return null;
|
|
538
|
+
const screens = await call(xinerama.QueryScreens.bind(xinerama));
|
|
539
|
+
return Array.isArray(screens) && screens.length ? screens.length : null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Is this XQuartz?
|
|
544
|
+
*
|
|
545
|
+
* It matters because XQuartz is not handing us a framebuffer: macOS composes
|
|
546
|
+
* the desktop in *points* and hands X the point space, already normalised
|
|
547
|
+
* for density. A 16" retina lid arrives as 1728x1080 and a 1x desk monitor
|
|
548
|
+
* beside it as 2560x1403 — the same 14pt text is the same physical size on
|
|
549
|
+
* both, and the window server scales what it is given onto whichever panel
|
|
550
|
+
* the window lands on. Every hardware rung below is therefore answering a
|
|
551
|
+
* question that was already answered: inferring 2x from the pixels would
|
|
552
|
+
* double a size macOS is about to double again.
|
|
553
|
+
*
|
|
554
|
+
* This is the `XWAYLAND` exemption in `VIRTUAL_OUTPUT_NAME` for the same
|
|
555
|
+
* reason and by the same rule — the compositor owns scaling, so we do not —
|
|
556
|
+
* and it sits below the configured rungs, not above them, because a person
|
|
557
|
+
* who typed `REACT_X11_SCALE` or `GDK_SCALE` outranks the platform.
|
|
558
|
+
*
|
|
559
|
+
* `Apple-WM` is the extension quartz-wm drives; it is present on every
|
|
560
|
+
* XQuartz server and on no other X server, including when the client is
|
|
561
|
+
* remote and only the display is a Mac (which is exactly when the point
|
|
562
|
+
* space is still the truth).
|
|
563
|
+
*/
|
|
564
|
+
function isQuartzServer(X) {
|
|
565
|
+
if (typeof X?.QueryExtension !== 'function') return Promise.resolve(false);
|
|
566
|
+
return new Promise((resolve) => {
|
|
567
|
+
try {
|
|
568
|
+
X.QueryExtension('Apple-WM', (err, reply) =>
|
|
569
|
+
resolve(!err && !!reply?.present),
|
|
570
|
+
);
|
|
571
|
+
} catch {
|
|
572
|
+
resolve(false);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
471
577
|
// --------------------------------------------------------------------------
|
|
472
578
|
// The session
|
|
473
579
|
// --------------------------------------------------------------------------
|
|
@@ -555,11 +661,35 @@ export async function beginScale(app, option) {
|
|
|
555
661
|
return session;
|
|
556
662
|
}
|
|
557
663
|
|
|
558
|
-
// Rungs 4-5: the hardware, one verdict per output
|
|
559
|
-
|
|
664
|
+
// Rungs 4-5: the hardware, one verdict per output — but only where the
|
|
665
|
+
// connection is describing hardware. The three reads go out together
|
|
666
|
+
// because none of them depends on another and this is the chain the first
|
|
667
|
+
// window waits behind.
|
|
668
|
+
const [quartz, outputs, heads] = await Promise.all([
|
|
669
|
+
isQuartzServer(X),
|
|
670
|
+
readOutputs(app),
|
|
671
|
+
countHeads(app),
|
|
672
|
+
]);
|
|
673
|
+
|
|
674
|
+
if (quartz) {
|
|
675
|
+
// 1, and not because nothing answered: macOS already did the scaling.
|
|
676
|
+
session.source = 'xquartz';
|
|
677
|
+
trace(
|
|
678
|
+
'1x from Apple-WM — XQuartz hands X macOS point space, already scaled',
|
|
679
|
+
);
|
|
680
|
+
return session;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const perPanel = !isUnionOutput(outputs, heads);
|
|
560
684
|
if (outputs) {
|
|
685
|
+
if (!perPanel) {
|
|
686
|
+
trace(
|
|
687
|
+
`${outputs.length} RandR output(s) against ${heads} Xinerama heads: ` +
|
|
688
|
+
'the output list is not per-panel, so the resolution class is out',
|
|
689
|
+
);
|
|
690
|
+
}
|
|
561
691
|
for (const monitor of outputs) {
|
|
562
|
-
const verdict = monitorScaleFromMetadata(monitor);
|
|
692
|
+
const verdict = monitorScaleFromMetadata(monitor, { perPanel });
|
|
563
693
|
session.monitors.set(monitor.name, {
|
|
564
694
|
scale: verdict.scale,
|
|
565
695
|
source: verdict.source,
|
package/src/screens.js
CHANGED
|
@@ -401,9 +401,6 @@ function watchLayout(session) {
|
|
|
401
401
|
const X = session.app.X;
|
|
402
402
|
const root = X.display?.screen?.[0]?.root;
|
|
403
403
|
if (root == null) return;
|
|
404
|
-
// PropertyChange on a window we do not own. Legal and shared — every
|
|
405
|
-
// panel-aware application on the desktop selects this same event.
|
|
406
|
-
X.ChangeWindowAttributes(root, { eventMask: PROPERTY_CHANGE_MASK }, () => {});
|
|
407
404
|
session.onEvent((ev) => {
|
|
408
405
|
if (session.stopped) return;
|
|
409
406
|
if (ev.type !== PROPERTY_NOTIFY || ev.wid !== root) return;
|
|
@@ -411,6 +408,27 @@ function watchLayout(session) {
|
|
|
411
408
|
relayout(session);
|
|
412
409
|
});
|
|
413
410
|
watchRandR(session);
|
|
411
|
+
// PropertyChange on a window we do not own. Legal and shared — every
|
|
412
|
+
// panel-aware application on the desktop selects this same event.
|
|
413
|
+
//
|
|
414
|
+
// Through ntk's root Window rather than as a raw ChangeWindowAttributes,
|
|
415
|
+
// because an X event mask is absolute per client: whoever writes last wins
|
|
416
|
+
// outright. ntk keeps selections additive by tracking the mask on a Window
|
|
417
|
+
// it caches per id, and adopting the root — which the shared-glyph
|
|
418
|
+
// directory does to hear MANAGER announcements — starts that tracked mask
|
|
419
|
+
// at zero and writes StructureNotify over whatever was there. A raw write
|
|
420
|
+
// is a value the next adopter silently overwrites, in either direction:
|
|
421
|
+
// reversed, it is a window manager's SubstructureRedirect that goes.
|
|
422
|
+
//
|
|
423
|
+
// Last in this function on purpose. `rootWindow()` constructs a Window,
|
|
424
|
+
// which can throw, and `beginScreens` calls this inside a bare catch — so
|
|
425
|
+
// ahead of the two registrations above, a throw here would take the
|
|
426
|
+
// work-area handler and the RandR watch with it rather than only the
|
|
427
|
+
// selection it belongs to.
|
|
428
|
+
session.app
|
|
429
|
+
.rootWindow()
|
|
430
|
+
.selectInput(PROPERTY_CHANGE_MASK)
|
|
431
|
+
.catch(() => {});
|
|
414
432
|
}
|
|
415
433
|
|
|
416
434
|
/** Re-read everything the layout is built from and publish once. */
|
package/src/windowstate.js
CHANGED
|
@@ -43,6 +43,7 @@ import { useTopLevelWindow, windowIdOf } from './windowid.js';
|
|
|
43
43
|
|
|
44
44
|
const VISIBILITY_NOTIFY = 15;
|
|
45
45
|
const VISIBILITY_CHANGE_MASK = 65536; // x11.eventMask.VisibilityChange
|
|
46
|
+
const PROPERTY_CHANGE_MASK = 4194304; // x11.eventMask.PropertyChange
|
|
46
47
|
const FULLY_OBSCURED = 2;
|
|
47
48
|
const DESKTOP_PROPERTY = '_NET_WM_DESKTOP';
|
|
48
49
|
|
|
@@ -198,6 +199,23 @@ function arm(session) {
|
|
|
198
199
|
if (!wnd) return;
|
|
199
200
|
session.armed = true;
|
|
200
201
|
|
|
202
|
+
// **One selection for everything this session needs.** Two masks are at
|
|
203
|
+
// stake — PropertyChange for `_NET_WM_STATE`/`_NET_WM_DESKTOP`, and
|
|
204
|
+
// VisibilityChange for the obscured watch — and ntk grows a window's mask
|
|
205
|
+
// one request per first listener of each kind. Asking for both here, before
|
|
206
|
+
// either subscription, makes the `statechange` listener below a detected
|
|
207
|
+
// no-op and leaves `watchVisibility` nothing to select: one
|
|
208
|
+
// ChangeWindowAttributes for a session instead of two, on a window that is
|
|
209
|
+
// already mapped and on screen by the time any of this runs.
|
|
210
|
+
if (typeof wnd.selectInput === 'function') {
|
|
211
|
+
Promise.resolve(
|
|
212
|
+
wnd.selectInput(PROPERTY_CHANGE_MASK | VISIBILITY_CHANGE_MASK),
|
|
213
|
+
).catch(() => {
|
|
214
|
+
// a window destroyed between the ref resolving and this call; the
|
|
215
|
+
// defaults stand and nothing further will arrive
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
201
219
|
// Focus, from the event manager rather than from ntk directly: it already
|
|
202
220
|
// dedups FocusIn/FocusOut and already knows the answer for a window that
|
|
203
221
|
// has not seen either yet.
|
|
@@ -269,9 +287,9 @@ function readDesktop(session) {
|
|
|
269
287
|
|
|
270
288
|
/**
|
|
271
289
|
* VisibilityNotify, which ntk has no event name for — its mask table stops
|
|
272
|
-
* at the events a widget toolkit needs — so the
|
|
273
|
-
*
|
|
274
|
-
*
|
|
290
|
+
* at the events a widget toolkit needs — so the event is read off the raw
|
|
291
|
+
* connection. The mask it needs was selected by `arm()`, together with the
|
|
292
|
+
* one the state watch needs.
|
|
275
293
|
*/
|
|
276
294
|
function watchVisibility(session) {
|
|
277
295
|
const wnd = session.node.window;
|
|
@@ -285,11 +303,6 @@ function watchVisibility(session) {
|
|
|
285
303
|
};
|
|
286
304
|
X.on('event', onEvent);
|
|
287
305
|
session._offs.push(() => X.off?.('event', onEvent));
|
|
288
|
-
|
|
289
|
-
Promise.resolve(wnd.selectInput(VISIBILITY_CHANGE_MASK)).catch(() => {
|
|
290
|
-
// a window destroyed between the ref resolving and this call; the
|
|
291
|
-
// defaults stand and nothing further will arrive
|
|
292
|
-
});
|
|
293
306
|
}
|
|
294
307
|
|
|
295
308
|
/** What is known about a window right now. Stable until it changes. */
|