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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.0.0",
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.3.0",
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 the two states in two
56
- // different places: hovering firms the ring up, and pressing greys the
57
- // inside as well. Three looks, which is the point — a hover the press
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
- pressed || hover
67
+ borderColor: focused
68
+ ? theme.borderFocus
69
+ : pressed || hover
62
70
  ? theme.textMuted
63
- : focused
64
- ? theme.borderFocus
65
- : theme.border,
66
- backgroundColor: pressed ? theme.surfaceActive : theme.surface,
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 &&
@@ -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
- : pressed || hover
115
- ? theme.textMuted
116
- : focused
117
- ? theme.borderFocus
123
+ : focused
124
+ ? theme.borderFocus
125
+ : pressed || hover
126
+ ? theme.textMuted
118
127
  : theme.border,
119
- backgroundColor: pressed ? theme.surfaceActive : theme.surface,
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 &&
@@ -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 { hooks as a11yHooks, isFocusable, effectivelyVisible } from './a11y.js';
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(':focus-visible', reason !== 'pointer');
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 (this.style.outlineWidth === undefined && !this.states[':focus-visible'])
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: `no credible density data (mm ${mm}, ${pxW}x${pxH})`,
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
- const outputs = await readOutputs(app);
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. */
@@ -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 mask goes on through
273
- * `selectInput` (which ORs into ntk's own tracked mask, so nothing it adds
274
- * later drops this) and the event is read off the raw connection.
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. */