react-x11 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +5 -3
  2. package/package.json +10 -3
  3. package/src/activate.js +12 -0
  4. package/src/anchor.js +6 -0
  5. package/src/appearance.js +351 -28
  6. package/src/appearancehooks.js +5 -2
  7. package/src/application.js +41 -0
  8. package/src/cocoa/app.js +358 -20
  9. package/src/cocoa/bezels.js +51 -1
  10. package/src/cocoa/context2d.js +271 -25
  11. package/src/cocoa/dnd.js +347 -0
  12. package/src/cocoa/dock.js +39 -0
  13. package/src/cocoa/filepanels.js +155 -0
  14. package/src/cocoa/fonts.js +93 -2
  15. package/src/cocoa/globalmenu.js +41 -33
  16. package/src/cocoa/notifications.js +244 -0
  17. package/src/cocoa/permissions.js +74 -0
  18. package/src/cocoa/presenter.js +190 -2
  19. package/src/cocoa/statusitem.js +112 -0
  20. package/src/cocoa/window.js +85 -4
  21. package/src/components/Button.js +20 -1
  22. package/src/components/Checkbox.js +17 -2
  23. package/src/components/Menu.js +108 -38
  24. package/src/components/Radio.js +17 -2
  25. package/src/components/Select.js +159 -27
  26. package/src/components/Switch.js +8 -1
  27. package/src/components/native.js +99 -0
  28. package/src/components/theme.js +37 -20
  29. package/src/desktopsettings.js +34 -2
  30. package/src/dnd.js +92 -3
  31. package/src/errors.js +6 -3
  32. package/src/filedialog.js +81 -16
  33. package/src/index.d.ts +17 -1
  34. package/src/index.js +17 -0
  35. package/src/launcher.js +170 -0
  36. package/src/launcherhooks.js +81 -0
  37. package/src/nodes.js +553 -35
  38. package/src/notificationhooks.js +56 -0
  39. package/src/notifications.js +558 -0
  40. package/src/palette.js +144 -8
  41. package/src/permissionhooks.js +89 -0
  42. package/src/permissions.js +196 -0
  43. package/src/screens.js +39 -4
  44. package/src/style.d.ts +10 -4
  45. package/src/style.js +1 -0
  46. package/src/styles.js +161 -15
  47. package/src/textselection.js +1 -4
  48. package/src/trayhooks.js +90 -0
  49. package/src/types/appearance.d.ts +24 -0
  50. package/src/types/components.d.ts +10 -0
  51. package/src/types/elements.d.ts +14 -0
  52. package/src/types/events.d.ts +14 -0
  53. package/src/types/filedialog.d.ts +18 -7
  54. package/src/types/launcher.d.ts +43 -0
  55. package/src/types/notifications.d.ts +113 -0
  56. package/src/types/permissions.d.ts +100 -0
  57. package/src/types/style.d.ts +30 -2
  58. package/src/types/system.d.ts +5 -3
  59. package/src/types/tray.d.ts +54 -0
  60. package/src/windowid.js +23 -0
package/src/palette.js CHANGED
@@ -9,11 +9,12 @@
9
9
  //
10
10
  // **The default follows the desktop.** A react-x11 app that says nothing
11
11
  // about colour is dark on a dark desktop and light on a light one, the way a
12
- // GTK or Qt app is; `<ThemeProvider>` and `colorScheme` are how an app that
13
- // wants otherwise says so. See docs/appearance.md.
12
+ // GTK or Qt app is, and its accent is the desktop's where the desktop has
13
+ // one; `<ThemeProvider>` and `colorScheme` are how an app that wants
14
+ // otherwise says so. See docs/appearance.md.
14
15
 
15
16
  import { appearanceSnapshot } from './appearance.js';
16
- import { readableInk, stepBeyond } from './styles.js';
17
+ import { readableInk, stepBeyond, tint } from './styles.js';
17
18
 
18
19
  /**
19
20
  * The language subtags written right-to-left — CLDR's set, by the language
@@ -171,6 +172,17 @@ export const DefaultTheme = {
171
172
  focusRing: '#2980b9',
172
173
  focusRingWidth: 2,
173
174
  focusRingOffset: 1,
175
+ // The highlight behind selected text. A *tint* rather than an opaque
176
+ // fill, so the ink keeps its own contrast on both palettes; derived from
177
+ // `accent` for any palette that moves the accent and not this. A desktop
178
+ // that names one (macOS's Highlight colour) gives an opaque fill here.
179
+ selection: tint('#2980b9', 0.35),
180
+ // The insertion caret. `null` is the text's own colour, which is what a
181
+ // caret is unless the desktop says otherwise — macOS draws it in the
182
+ // Highlight colour's accent.
183
+ caret: null,
184
+ // Ink for a link. Not the accent: a link is blue everywhere, as a note is.
185
+ link: '#1c6ea4',
174
186
  // shape
175
187
  radius: 4,
176
188
  radiusSmall: 3,
@@ -268,6 +280,9 @@ const TEXT_FROM = {
268
280
  infoText: 'info',
269
281
  };
270
282
 
283
+ // The selection tint follows the accent it is a tint of.
284
+ const TINT_FROM = { selection: ['accent', 0.35] };
285
+
271
286
  // And the same for the floating-surface radii, which are a function of the
272
287
  // text they wrap: a palette that sets `fontSize` and nothing else still gets
273
288
  // menus in proportion to it.
@@ -323,6 +338,11 @@ export function resolveTheme(value, base = DefaultTheme) {
323
338
  continue;
324
339
  merged[token] = readableInk(merged[fill], [merged.text, merged.background]);
325
340
  }
341
+ for (const [token, [fill, alpha]] of Object.entries(TINT_FROM)) {
342
+ if (named[token] == null && named[fill] != null) {
343
+ merged[token] = tint(merged[fill], alpha);
344
+ }
345
+ }
326
346
  if (named.fontSize != null) {
327
347
  for (const [token, from] of Object.entries(RADIUS_FROM_FONT)) {
328
348
  if (named[token] == null) merged[token] = from(merged.fontSize);
@@ -377,18 +397,134 @@ export const DarkTheme = resolveTheme({
377
397
  hoverText: 'white',
378
398
  borderFocus: '#5aa4e6',
379
399
  focusRing: '#5aa4e6',
400
+ link: '#5aa4e6',
380
401
  });
381
402
 
403
+ /**
404
+ * `#rrggbb` moved `amount` of the way toward `toward`. The one colour
405
+ * operation the desktop palette needs, on the one shape the ladder
406
+ * guarantees an accent has (`sanitize`, `accentFromPortal`).
407
+ */
408
+ function mixHex(hex, toward, amount) {
409
+ const channel = (c, i) => parseInt(c.slice(1 + 2 * i, 3 + 2 * i), 16);
410
+ return (
411
+ '#' +
412
+ [0, 1, 2]
413
+ .map((i) => {
414
+ const a = channel(hex, i);
415
+ const b = channel(toward, i);
416
+ return Math.round(a + (b - a) * amount)
417
+ .toString(16)
418
+ .padStart(2, '0');
419
+ })
420
+ .join('')
421
+ );
422
+ }
423
+
424
+ /**
425
+ * The built-in palette with the desktop's accent on it.
426
+ *
427
+ * Only the accent family moves — `accent`, its hover, the menu-row highlight
428
+ * (the desktop's own selection shade where it names one: on macOS that is a
429
+ * darker cut of the accent, and a row lit in the raw accent beside a native
430
+ * menu reads as too bright; the accent itself otherwise, as in both built-in
431
+ * palettes), and the focus ring —
432
+ * and the steps are taken the way each palette takes them: the hover sinks
433
+ * into a light ground and lifts off a dark one, and `resolveTheme` derives
434
+ * the press from the pair. `info` stays its own blue, as it does under a
435
+ * theme with a green accent: a note looks like a note everywhere.
436
+ *
437
+ * The ink is the desktop's where the desktop named one — AppKit writes white
438
+ * on every accent a Mac offers, including the ones a contrast ratio would
439
+ * put dark letters on, and the point of following the desktop is to look
440
+ * like the controls beside ours. Where the source has no ink (the portal)
441
+ * `resolveTheme` picks the legible one, as it does for any theme that names
442
+ * a fill and stops there.
443
+ */
444
+ function withDesktopAccent(scheme, accent, ink, selection) {
445
+ const dark = scheme.scheme === 'dark';
446
+ const accentHover = dark
447
+ ? mixHex(accent, '#ffffff', 0.2)
448
+ : mixHex(accent, '#000000', 0.22);
449
+ const focus = dark ? accentHover : accent;
450
+ const value = {
451
+ accent,
452
+ accentHover,
453
+ hoverBackground: selection ?? accent,
454
+ borderFocus: focus,
455
+ focusRing: focus,
456
+ };
457
+ if (ink) {
458
+ value.accentText = ink;
459
+ value.hoverText = ink;
460
+ }
461
+ return resolveTheme(value, scheme);
462
+ }
463
+
464
+ // A desktop that names its whole palette (macOS, through the semantic
465
+ // colours AppKit paints with) is merged over the scheme's built-in one the
466
+ // way a `<ThemeProvider>` value is: every colour it named, and the shape
467
+ // tokens, the derived inks and the pressed steps from `resolveTheme`. Keyed
468
+ // on the palette object itself — the store replaces it, never mutates it.
469
+ const desktopPalettesByObject = new WeakMap();
470
+ function withDesktopPalette(scheme, palette) {
471
+ let byScheme = desktopPalettesByObject.get(palette);
472
+ if (!byScheme) {
473
+ byScheme = new Map();
474
+ desktopPalettesByObject.set(palette, byScheme);
475
+ }
476
+ let resolved = byScheme.get(scheme);
477
+ if (!resolved) {
478
+ resolved = resolveTheme(palette, scheme);
479
+ byScheme.set(scheme, resolved);
480
+ }
481
+ return resolved;
482
+ }
483
+
484
+ // One palette per desktop answer, so the unprovided palette keeps the
485
+ // identity `useTheme()` and the `$token` resolution cache both count on: a
486
+ // fresh object per read would re-resolve every token in the tree on every
487
+ // paint. Bounded because a desktop's accent changes a handful of times in the
488
+ // life of a process, never per frame.
489
+ const desktopPalettes = new Map();
490
+
491
+ /**
492
+ * The built-in palette for a desktop that looks like `appearance` — the
493
+ * scheme's own, with the desktop's accent on it where the desktop named one.
494
+ *
495
+ * **The default follows the accent as well as the scheme.** An app that says
496
+ * nothing about colour is asking to look like it belongs on this desktop, and
497
+ * on the one that reports an accent every native control beside it is already
498
+ * that colour — the Cocoa backend draws its bezels with AppKit, so the app's
499
+ * own checkbox is orange while its `<Tabs>` indicator stayed blue. An app
500
+ * with a brand names `accent` in its `<ThemeProvider>` and keeps it; a pinned
501
+ * `colorScheme` follows nothing, the accent included.
502
+ *
503
+ * `'no-preference'` means *use your own default*, which is the light one.
504
+ */
505
+ export function paletteFor(appearance) {
506
+ const scheme = appearance.colorScheme === 'dark' ? DarkTheme : DefaultTheme;
507
+ if (appearance.palette) return withDesktopPalette(scheme, appearance.palette);
508
+ const { accent, accentText, selection } = appearance;
509
+ if (!accent) return scheme;
510
+ const key = `${scheme.scheme} ${accent} ${accentText ?? ''} ${selection ?? ''}`;
511
+ let palette = desktopPalettes.get(key);
512
+ if (!palette) {
513
+ if (desktopPalettes.size >= 8) desktopPalettes.clear();
514
+ palette = withDesktopAccent(scheme, accent, accentText, selection);
515
+ desktopPalettes.set(key, palette);
516
+ }
517
+ return palette;
518
+ }
519
+
382
520
  /**
383
521
  * The palette in force where nothing has been said — which is to say, the
384
522
  * desktop's.
385
523
  *
386
524
  * Read synchronously and cheaply: `appearanceSnapshot()` is a frozen object
387
- * seeded from disk before the first render, so this is a property lookup and
388
- * a comparison, and it is called from the paint path.
389
- *
390
- * `'no-preference'` means *use your own default*, which is the light one.
525
+ * seeded from disk before the first render, so this is a property lookup, a
526
+ * comparison and a map hit, and it is called from the paint path.
391
527
  */
392
528
  export function baseTheme() {
393
- return appearanceSnapshot().colorScheme === 'dark' ? DarkTheme : DefaultTheme;
529
+ return paletteFor(appearanceSnapshot());
394
530
  }
@@ -0,0 +1,89 @@
1
+ // `usePermission()` — a permission as a component sees it: the status as
2
+ // render state, a request that updates it, and whether this backend can
3
+ // answer at all.
4
+ //
5
+ // The bare functions in `permissions.js` are complete; what a component
6
+ // wants on top is binding — the tree's connection, the status re-read after
7
+ // a request — not another rung. There is no change notification behind a
8
+ // status on any platform, so the status is what was last read: on mount,
9
+ // and after each `request()`. An app that wants it fresher re-reads with
10
+ // `refresh()` on its own cue (a window coming back to the front, say).
11
+
12
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
13
+
14
+ import { useAppOrNull } from './appcontext.js';
15
+ import {
16
+ openPrivacySettings,
17
+ permissionBackend,
18
+ permissionStatus,
19
+ requestPermission,
20
+ } from './permissions.js';
21
+
22
+ /**
23
+ * ```jsx
24
+ * const camera = usePermission('camera');
25
+ *
26
+ * {camera.available && camera.status !== 'granted' && (
27
+ * <Button
28
+ * label={camera.status === 'prompt' ? 'Allow camera' : 'Open Settings'}
29
+ * onPress={() =>
30
+ * camera.status === 'prompt' ? camera.request() : camera.openSettings()
31
+ * }
32
+ * />
33
+ * )}
34
+ * ```
35
+ *
36
+ * `status` starts `'unknown'` and settles once read. `request()` resolves
37
+ * with the status after the user answered and updates `status` with it;
38
+ * while one is in flight a second call returns the same promise.
39
+ * `available` is whether this backend has an authorization API — false on
40
+ * X11 today — and the honest branch to keep a feature behind.
41
+ */
42
+ export function usePermission(kind, options = {}) {
43
+ const app = useAppOrNull();
44
+ const target = options.target;
45
+ const available = permissionBackend({ app }) !== null;
46
+ const [status, setStatus] = useState('unknown');
47
+ const inflight = useRef(null);
48
+
49
+ const refresh = useCallback(async () => {
50
+ const next = await permissionStatus(kind, { app, target });
51
+ setStatus(next);
52
+ return next;
53
+ }, [kind, app, target]);
54
+
55
+ useEffect(() => {
56
+ let alive = true;
57
+ permissionStatus(kind, { app, target }).then(
58
+ (next) => alive && setStatus(next),
59
+ () => alive && setStatus('unknown'),
60
+ );
61
+ return () => {
62
+ alive = false;
63
+ };
64
+ }, [kind, app, target]);
65
+
66
+ const request = useCallback(() => {
67
+ if (inflight.current) return inflight.current;
68
+ const run = requestPermission(kind, { app, target })
69
+ .then((next) => {
70
+ setStatus(next);
71
+ return next;
72
+ })
73
+ .finally(() => {
74
+ inflight.current = null;
75
+ });
76
+ inflight.current = run;
77
+ return run;
78
+ }, [kind, app, target]);
79
+
80
+ const openSettings = useCallback(
81
+ () => openPrivacySettings(kind, { app }),
82
+ [kind, app],
83
+ );
84
+
85
+ return useMemo(
86
+ () => ({ status, available, request, refresh, openSettings }),
87
+ [status, available, request, refresh, openSettings],
88
+ );
89
+ }
@@ -0,0 +1,196 @@
1
+ // Permissions: may this app use the camera, the microphone, the screen, the
2
+ // accessibility APIs — and how does it ask?
3
+ //
4
+ // The file dialog's ladder again (docs/filedialog.md), with one rung that
5
+ // answers and one that only points:
6
+ //
7
+ // 1. **the platform's own authorization** — the cocoa backend, where the
8
+ // bridge reads macOS's privacy (TCC) status per kind and raises the
9
+ // system prompt where a framework offers one (src/cocoa/permissions.js).
10
+ // Found by the app the tree renders through carrying `permissions`,
11
+ // never by naming a backend here.
12
+ // 2. **the Settings pane** — `open x-apple.systempreferences:…` on a Mac
13
+ // with no bridge (the X11 backend under XQuartz). It cannot read a
14
+ // status or raise a prompt; it can put the user in front of the switch,
15
+ // which is what `openPrivacySettings` promises and no more.
16
+ //
17
+ // On Linux nothing answers yet. The freedesktop counterparts are the
18
+ // per-device portals (`org.freedesktop.portal.Camera`, `.Location`), which
19
+ // need no bridge and are react-x11#466's remaining half; until then a
20
+ // status query says `'unknown'` and a request rejects with a **typed**
21
+ // error, the `NoFileDialogError` rule — a signal to keep the feature behind
22
+ // the answer, not a crash.
23
+ //
24
+ // ## Two things the vocabulary decides
25
+ //
26
+ // - A status is one of five words. `'granted'`, `'denied'` and
27
+ // `'restricted'` (MDM or parental controls: the user cannot grant it) are
28
+ // the platform's; `'prompt'` is "not decided yet — a request would ask";
29
+ // `'unknown'` is "nothing here can say", which is a fact about the machine
30
+ // rather than about the permission, and the reason a query never throws.
31
+ // - A request answers with the status **after** the user has, never with a
32
+ // bare boolean, because `'restricted'` and `'denied'` want different UI —
33
+ // one is a Settings switch the user can flip, the other is not.
34
+
35
+ import { liveApps } from './trace-registry.js';
36
+
37
+ /** The kinds a status can be asked for. `automation` wants `{ target }`. */
38
+ export const PERMISSION_KINDS = Object.freeze([
39
+ 'camera',
40
+ 'microphone',
41
+ 'screen-recording',
42
+ 'accessibility',
43
+ 'input-monitoring',
44
+ 'automation',
45
+ 'location',
46
+ ]);
47
+
48
+ /** The Settings panes, the kinds above plus the two that have no API at all
49
+ * because reading the folder *is* the prompt. */
50
+ const SETTINGS_PANES = Object.freeze({
51
+ camera: 'Privacy_Camera',
52
+ microphone: 'Privacy_Microphone',
53
+ 'screen-recording': 'Privacy_ScreenCapture',
54
+ accessibility: 'Privacy_Accessibility',
55
+ 'input-monitoring': 'Privacy_ListenEvent',
56
+ automation: 'Privacy_Automation',
57
+ location: 'Privacy_LocationServices',
58
+ 'files-and-folders': 'Privacy_FilesAndFolders',
59
+ 'full-disk-access': 'Privacy_AllFiles',
60
+ });
61
+
62
+ /**
63
+ * Nothing on this machine can ask. A **typed** rejection, so a caller keeps
64
+ * the feature behind it rather than crashing — `usePermission().available`
65
+ * is that branch as render state.
66
+ */
67
+ export class NoPermissionServiceError extends Error {
68
+ constructor(kind, cause) {
69
+ super(
70
+ `react-x11: nothing here can ask for the ${kind} permission — this ` +
71
+ 'backend has no authorization API (the cocoa backend has; the ' +
72
+ 'freedesktop device portals are not implemented yet). Read ' +
73
+ "permissionStatus() first: 'unknown' is this machine's answer.",
74
+ { cause },
75
+ );
76
+ this.name = 'NoPermissionServiceError';
77
+ }
78
+ }
79
+
80
+ function checkKind(kind) {
81
+ if (!PERMISSION_KINDS.includes(kind)) {
82
+ throw new TypeError(
83
+ `react-x11: ${JSON.stringify(kind)} is not a permission kind. ` +
84
+ `Expected one of ${PERMISSION_KINDS.join(', ')}.`,
85
+ );
86
+ }
87
+ }
88
+
89
+ /** The deep link into System Settings for a pane, or the Privacy pane. */
90
+ export function privacySettingsUrl(kind) {
91
+ const pane = kind == null ? null : SETTINGS_PANES[kind];
92
+ if (kind != null && !pane) {
93
+ throw new TypeError(
94
+ `react-x11: ${JSON.stringify(kind)} is not a privacy pane. Expected ` +
95
+ `one of ${Object.keys(SETTINGS_PANES).join(', ')}.`,
96
+ );
97
+ }
98
+ return (
99
+ 'x-apple.systempreferences:com.apple.preference.security' +
100
+ (pane ? `?${pane}` : '?Privacy')
101
+ );
102
+ }
103
+
104
+ /** The app whose authorization API to ask when the caller did not say. */
105
+ function soleApp() {
106
+ const apps = liveApps();
107
+ if (apps.length <= 1) return apps[0] ?? null;
108
+ const showing = apps.filter((app) => (app._rootChildren ?? []).length > 0);
109
+ return showing.length === 1 ? showing[0] : null;
110
+ }
111
+
112
+ function serviceFor({ app } = {}) {
113
+ const target = app ?? soleApp();
114
+ return target?.permissions ?? null;
115
+ }
116
+
117
+ /**
118
+ * Which rung answers here, without asking anything: `'cocoa'` where the
119
+ * tree's backend has an authorization API, `null` where a status would be
120
+ * `'unknown'` and a request would reject. Synchronous — a capability, not
121
+ * a probe.
122
+ *
123
+ * @returns {'cocoa' | null}
124
+ */
125
+ export function permissionBackend(options = {}) {
126
+ return serviceFor(options) ? 'cocoa' : null;
127
+ }
128
+
129
+ /**
130
+ * Whether this app may use `kind`, without prompting.
131
+ *
132
+ * ```js
133
+ * const status = await permissionStatus('camera');
134
+ * // 'granted' | 'denied' | 'restricted' | 'prompt' | 'unknown'
135
+ * ```
136
+ *
137
+ * Never rejects for anything about the machine: `'unknown'` is the answer
138
+ * where nothing can say. `automation` asks about one target app, by bundle
139
+ * id, and only a running one has an answer (`{ target }`).
140
+ */
141
+ export async function permissionStatus(kind, options = {}) {
142
+ checkKind(kind);
143
+ const service = serviceFor(options);
144
+ if (!service) return 'unknown';
145
+ return service.status(kind, options);
146
+ }
147
+
148
+ /**
149
+ * Ask for `kind`, raising the system's prompt where the platform has one,
150
+ * and resolve with the status once the user has answered.
151
+ *
152
+ * ```js
153
+ * const status = await requestPermission('microphone');
154
+ * if (status === 'granted') startRecording();
155
+ * else if (status === 'denied') await openPrivacySettings('microphone');
156
+ * ```
157
+ *
158
+ * A status the user has already decided answers at once, without a prompt.
159
+ * Where the platform's only "prompt" is a dialog that sends the user to
160
+ * Settings (screen recording, accessibility) the request resolves as soon
161
+ * as that dialog is up, with the status as it stands — `'denied'` until the
162
+ * switch is flipped, and for screen recording a restart after it.
163
+ *
164
+ * Rejects with {@link NoPermissionServiceError} where nothing can ask —
165
+ * which `permissionStatus` foretells with `'unknown'`.
166
+ */
167
+ export async function requestPermission(kind, options = {}) {
168
+ checkKind(kind);
169
+ const service = serviceFor(options);
170
+ if (!service) throw new NoPermissionServiceError(kind);
171
+ return service.request(kind, options);
172
+ }
173
+
174
+ /**
175
+ * Put the user in front of the switch: System Settings › Privacy & Security
176
+ * › `kind`, or the Privacy pane itself with no kind. The two panes with no
177
+ * API — `files-and-folders`, `full-disk-access` — are reachable here too.
178
+ *
179
+ * Through the bridge where there is one, and by `open` on any Mac
180
+ * otherwise, since a deep link needs no framework. Resolves to whether
181
+ * anything opened: `false` off macOS, where there is no such pane.
182
+ */
183
+ export async function openPrivacySettings(kind, options = {}) {
184
+ const url = privacySettingsUrl(kind);
185
+ const service = serviceFor(options);
186
+ if (service) return service.openSettings(kind);
187
+ if (process.platform !== 'darwin') return false;
188
+ const { execFile } = await import('node:child_process');
189
+ return new Promise((resolve) => {
190
+ try {
191
+ execFile('open', [url], (error) => resolve(!error));
192
+ } catch {
193
+ resolve(false);
194
+ }
195
+ });
196
+ }
package/src/screens.js CHANGED
@@ -72,12 +72,17 @@
72
72
  * Letting the WM have the last word costs a clamped-to-the-edge window one
73
73
  * correction it would have made anyway.
74
74
  *
75
- * **A per-monitor work area.** `_NET_WORKAREA` is one rect for the whole
76
- * virtual desktop. Deriving a real per-monitor one means reading
75
+ * **A per-monitor work area, on X11.** `_NET_WORKAREA` is one rect for the
76
+ * whole virtual desktop. Deriving a real per-monitor one means reading
77
77
  * `_NET_WM_STRUT_PARTIAL` off every window on the screen and intersecting
78
78
  * the reservations that fall on each head — a full window-tree walk, redone
79
79
  * whenever any panel changes. `available` below is the per-axis
80
80
  * approximation instead, and says so.
81
+ *
82
+ * A backend that *does* know each monitor's usable rect says so directly
83
+ * instead: a `visible` rect on the monitor record, which `usable()` prefers
84
+ * over the whole-desktop compromise. Cocoa's `NSScreen.visibleFrame` is
85
+ * exactly that, so the macOS backend never goes through the approximation.
81
86
  */
82
87
 
83
88
  import { requireExtension } from './extensions.js';
@@ -206,9 +211,36 @@ function monitorAt(monitors, point) {
206
211
  return best;
207
212
  }
208
213
 
214
+ /** The overlap of two rects, or `null` where they do not touch. */
215
+ function intersect(a, b) {
216
+ const x0 = Math.max(a.x, b.x);
217
+ const y0 = Math.max(a.y, b.y);
218
+ const x1 = Math.min(a.x + a.width, b.x + b.width);
219
+ const y1 = Math.min(a.y + a.height, b.y + b.height);
220
+ if (x1 <= x0 || y1 <= y0) return null;
221
+ return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
222
+ }
223
+
209
224
  /**
210
- * The monitor rect clamped per axis by `_NET_WORKAREA` — see the note on
211
- * per-monitor work areas at the top of the file.
225
+ * The usable part of one monitor.
226
+ *
227
+ * Two ways to get there, and the first one is the real answer:
228
+ *
229
+ * - A monitor record may carry its **own** usable rect as `visible`, in the
230
+ * same screen coordinates as the monitor itself. That is what Cocoa's
231
+ * `NSScreen.visibleFrame` is — per screen, minus that screen's menu bar
232
+ * and Dock — and it is taken as a rect, intersected with the monitor in
233
+ * case a backend reports one that overhangs.
234
+ * - Otherwise the monitor rect clamped **per axis** by `_NET_WORKAREA`,
235
+ * which is one rect for the whole virtual desktop and so cannot be
236
+ * positioned against a single head — see the note at the top of the file.
237
+ *
238
+ * The distinction matters the moment the monitors differ in size. A global
239
+ * work area no wider than the primary, applied as a width bound to a wider
240
+ * second display, moves that display's right edge inward by the difference
241
+ * and every anchored popup with it. On X11 the property spans the virtual
242
+ * desktop and the clamp is a no-op on the width, which is why the
243
+ * approximation held there and only there.
212
244
  *
213
245
  * Always **only** a rect. A monitor record carries a name, a primary flag and
214
246
  * physical sizes as well, and spreading it here put all of that inside
@@ -221,6 +253,7 @@ function usable(monitor, work) {
221
253
  width: monitor.width,
222
254
  height: monitor.height,
223
255
  };
256
+ if (monitor.visible) return intersect(rect, monitor.visible) ?? rect;
224
257
  if (!work) return rect;
225
258
  rect.width = Math.min(rect.width, work.width);
226
259
  rect.height = Math.min(rect.height, work.height);
@@ -756,6 +789,8 @@ export function endScreens(app) {
756
789
  * `monitors` entries may carry the RandR fields (`name`, `primary`,
757
790
  * `widthMM`, `heightMM`, `refreshRate`, `rotation`) as well as the rect, so
758
791
  * a test can state a named two-head desktop without a server that has RandR.
792
+ * An entry may also carry its own `visible` rect — the monitor's usable
793
+ * area, which takes precedence over the whole-desktop `workArea`.
759
794
  */
760
795
  export function setScreensForTests(app, { monitors = null, workArea = null }) {
761
796
  let session = sessions.get(app);
package/src/style.d.ts CHANGED
@@ -58,21 +58,27 @@ export function styleHasSizeQueries(style: StyleProperties): boolean;
58
58
  /** Does the style carry a `'@supports …'` block? Those are re-resolved when
59
59
  * the server's answer changes, not when the window is laid out. */
60
60
  export function styleHasSupportsQueries(style: StyleProperties): boolean;
61
+ /** Does the style carry a `'@container …'` block? Those are re-resolved after
62
+ * a layout pass moved the container they ask about. */
63
+ export function styleHasContainerQueries(style: StyleProperties): boolean;
61
64
  /** `resolveQueries` with only the size half — the shape this had before
62
65
  * capability blocks existed. */
63
66
  export function resolveSizeQueries(
64
67
  style: StyleProperties,
65
68
  size: { width: number; height: number },
66
69
  ): StyleProperties;
67
- /** Merge every matching `@` block — size and capability alike — in
68
- * declaration order. `supports` maps feature name to whether the window can
69
- * actually do it; a missing map matches nothing, which is the safe way
70
- * round. */
70
+ /** Merge every matching `@` block — size, container and capability alike —
71
+ * in declaration order. `supports` maps feature name to whether the window
72
+ * can actually do it; `containers` maps a container name (`''` for the
73
+ * unnamed query) to that container's size in the node's logical pixels. A
74
+ * missing map, or a name it does not hold, matches nothing, which is the
75
+ * safe way round. */
71
76
  export function resolveQueries(
72
77
  style: StyleProperties,
73
78
  context?: {
74
79
  size?: { width: number; height: number } | null;
75
80
  supports?: Record<string, boolean> | null;
81
+ containers?: Record<string, { width: number; height: number }> | null;
76
82
  },
77
83
  ): StyleProperties;
78
84
 
package/src/style.js CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  resolveTokens,
18
18
  styleHasSizeQueries,
19
19
  styleHasSupportsQueries,
20
+ styleHasContainerQueries,
20
21
  resolveSizeQueries,
21
22
  resolveQueries,
22
23
  interpolate,