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.
- package/README.md +5 -3
- package/package.json +10 -3
- package/src/activate.js +12 -0
- package/src/anchor.js +6 -0
- package/src/appearance.js +351 -28
- package/src/appearancehooks.js +5 -2
- package/src/application.js +41 -0
- package/src/cocoa/app.js +358 -20
- package/src/cocoa/bezels.js +51 -1
- package/src/cocoa/context2d.js +271 -25
- package/src/cocoa/dnd.js +347 -0
- package/src/cocoa/dock.js +39 -0
- package/src/cocoa/filepanels.js +155 -0
- package/src/cocoa/fonts.js +93 -2
- package/src/cocoa/globalmenu.js +41 -33
- package/src/cocoa/notifications.js +244 -0
- package/src/cocoa/permissions.js +74 -0
- package/src/cocoa/presenter.js +190 -2
- package/src/cocoa/statusitem.js +112 -0
- package/src/cocoa/window.js +85 -4
- package/src/components/Button.js +20 -1
- package/src/components/Checkbox.js +17 -2
- package/src/components/Menu.js +108 -38
- package/src/components/Radio.js +17 -2
- package/src/components/Select.js +159 -27
- package/src/components/Switch.js +8 -1
- package/src/components/native.js +99 -0
- package/src/components/theme.js +37 -20
- package/src/desktopsettings.js +34 -2
- package/src/dnd.js +92 -3
- package/src/errors.js +6 -3
- package/src/filedialog.js +81 -16
- package/src/index.d.ts +17 -1
- package/src/index.js +17 -0
- package/src/launcher.js +170 -0
- package/src/launcherhooks.js +81 -0
- package/src/nodes.js +553 -35
- package/src/notificationhooks.js +56 -0
- package/src/notifications.js +558 -0
- package/src/palette.js +144 -8
- package/src/permissionhooks.js +89 -0
- package/src/permissions.js +196 -0
- package/src/screens.js +39 -4
- package/src/style.d.ts +10 -4
- package/src/style.js +1 -0
- package/src/styles.js +161 -15
- package/src/textselection.js +1 -4
- package/src/trayhooks.js +90 -0
- package/src/types/appearance.d.ts +24 -0
- package/src/types/components.d.ts +10 -0
- package/src/types/elements.d.ts +14 -0
- package/src/types/events.d.ts +14 -0
- package/src/types/filedialog.d.ts +18 -7
- package/src/types/launcher.d.ts +43 -0
- package/src/types/notifications.d.ts +113 -0
- package/src/types/permissions.d.ts +100 -0
- package/src/types/style.d.ts +30 -2
- package/src/types/system.d.ts +5 -3
- package/src/types/tray.d.ts +54 -0
- package/src/windowid.js +23 -0
package/README.md
CHANGED
|
@@ -100,9 +100,9 @@ three:
|
|
|
100
100
|
| ------------------------------------------------- | ------------------------------------------------------- |
|
|
101
101
|
|  |  |
|
|
102
102
|
|
|
103
|
-
| `examples/form.jsx` — textinput + Select | the open Select menu (a real `<popup>` window) |
|
|
104
|
-
|
|
|
105
|
-
| 
|
|
103
|
+
| `examples/form/index.jsx` — textinput + Select | the open Select menu (a real `<popup>` window) |
|
|
104
|
+
| ---------------------------------------------- | ---------------------------------------------- |
|
|
105
|
+
|  |  |
|
|
106
106
|
|
|
107
107
|
`examples/viewer3d.jsx` — a model viewer over **indirect GLX**: the GL
|
|
108
108
|
protocol sent over the X connection, geometry compiled into a display list,
|
|
@@ -270,12 +270,14 @@ explore them:
|
|
|
270
270
|
npm run examples:simple # hello world (JSX via tsx)
|
|
271
271
|
npm run examples:app # the showcase: Tabs + SplitPane hosting the rest
|
|
272
272
|
npm run examples:theming # three themes x light/dark, and a size query
|
|
273
|
+
npm run examples:container-queries # one card, two panes: '@container', and a named one
|
|
273
274
|
npm run examples:simple-nojsx # the same, plain node — no build step
|
|
274
275
|
npm run examples:xeyes # canvas drawing + hooks
|
|
275
276
|
npm run examples:dashboard # context theming, custom hooks, components
|
|
276
277
|
npm run examples:tasks # useReducer, textinput, scrolling
|
|
277
278
|
npm run examples:menu # right-click context menu via <popup>
|
|
278
279
|
npm run examples:transparent # rounded translucent <popup transparent>
|
|
280
|
+
npm run examples:animation # transitions and loops, and which backend runs them
|
|
279
281
|
npm run examples:form # <textinput> + Select dropdowns
|
|
280
282
|
npm run examples:datepicker # Calendar/DatePicker: ranges, blocked days, events
|
|
281
283
|
npm run examples:password # PasswordInput: the scribble mask, and a custom one
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-x11",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "react renderer with X11 as a target",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"files": [
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"format:check": "prettier --check .",
|
|
15
15
|
"examples:app": "tsx examples/app.jsx",
|
|
16
16
|
"examples:theming": "tsx examples/theming.jsx",
|
|
17
|
+
"examples:container-queries": "tsx examples/container-queries.jsx",
|
|
17
18
|
"examples:appearance": "tsx examples/appearance.jsx",
|
|
19
|
+
"examples:animation": "tsx examples/animation.jsx",
|
|
18
20
|
"examples:simple": "tsx examples/simple.jsx",
|
|
19
21
|
"examples:simple-nojsx": "node examples/simple-nojsx.js",
|
|
20
22
|
"examples:xeyes": "tsx examples/xeyes.jsx",
|
|
@@ -22,8 +24,11 @@
|
|
|
22
24
|
"examples:tasks": "tsx examples/tasks.jsx",
|
|
23
25
|
"examples:tasks:hot": "node --enable-source-maps --import react-x11/refresh/register examples/tasks-hot.jsx",
|
|
24
26
|
"examples:menu": "tsx examples/menu.jsx",
|
|
27
|
+
"examples:notify": "tsx examples/notify.jsx",
|
|
25
28
|
"examples:monitor": "tsx examples/monitor.jsx",
|
|
26
29
|
"examples:attention": "tsx examples/attention.jsx",
|
|
30
|
+
"examples:badge": "tsx examples/badge.jsx",
|
|
31
|
+
"examples:tray": "tsx examples/tray.jsx",
|
|
27
32
|
"examples:tooltips": "tsx examples/tooltips.jsx",
|
|
28
33
|
"examples:chat": "tsx examples/chat.jsx",
|
|
29
34
|
"examples:clipboard": "tsx examples/clipboard.jsx",
|
|
@@ -36,7 +41,8 @@
|
|
|
36
41
|
"devtools:proxy": "node scripts/devtools-proxy.mjs",
|
|
37
42
|
"examples:configurator": "tsx examples/configurator/index.jsx",
|
|
38
43
|
"examples:fonts": "tsx examples/fonts.jsx",
|
|
39
|
-
"examples:form": "tsx examples/form.jsx",
|
|
44
|
+
"examples:form": "tsx examples/form/index.jsx",
|
|
45
|
+
"examples:form:app": "sh examples/form/make-app.sh && open examples/form/build/Guestbook.app",
|
|
40
46
|
"examples:rules": "tsx examples/rules.jsx",
|
|
41
47
|
"examples:selection": "tsx examples/selection.jsx",
|
|
42
48
|
"examples:settings": "tsx examples/settings.jsx",
|
|
@@ -86,12 +92,13 @@
|
|
|
86
92
|
"node": ">=20.19"
|
|
87
93
|
},
|
|
88
94
|
"dependencies": {
|
|
95
|
+
"linebreak": "^1.1.0",
|
|
89
96
|
"ntk": "^8.7.0",
|
|
90
97
|
"react-reconciler": "^0.33.0",
|
|
91
98
|
"yoga-layout": "^3.2.1"
|
|
92
99
|
},
|
|
93
100
|
"optionalDependencies": {
|
|
94
|
-
"@windowkit/appkit": "^0.
|
|
101
|
+
"@windowkit/appkit": "^0.5.0",
|
|
95
102
|
"dbus-native": "^0.15.1",
|
|
96
103
|
"x11-dri": "^0.7.0"
|
|
97
104
|
},
|
package/src/activate.js
CHANGED
|
@@ -143,6 +143,18 @@ export function activateWindow(target, { timestamp, source } = {}) {
|
|
|
143
143
|
const app = node?.app ?? node?.root?.app ?? wnd?.app ?? soleApp();
|
|
144
144
|
|
|
145
145
|
const chosen = wnd ?? (windowIdOf(node) === null ? inferWindow(app) : null);
|
|
146
|
+
|
|
147
|
+
// The cocoa backend has no window manager and no `_NET_ACTIVE_WINDOW` to
|
|
148
|
+
// send — the raise is `NSApp.activate` plus ordering the window front,
|
|
149
|
+
// which the app object performs (src/cocoa/app.js `raiseWindow`).
|
|
150
|
+
// Feature-detected so the X11 path below is untouched, and so this stops
|
|
151
|
+
// reporting the false success the no-op X stub would: on cocoa `app.X` is
|
|
152
|
+
// a shim whose `InternAtom`/`SendClientMessage` go nowhere yet still let
|
|
153
|
+
// the code below return `true`.
|
|
154
|
+
if (app && typeof app.raiseWindow === 'function') {
|
|
155
|
+
return app.raiseWindow(chosen);
|
|
156
|
+
}
|
|
157
|
+
|
|
146
158
|
const xid = windowIdOf(chosen) ?? windowIdOf(node);
|
|
147
159
|
const X = ntkWindowOf(chosen)?.X ?? wnd?.X ?? app?.X;
|
|
148
160
|
const root = X?.display?.screen?.[0]?.root;
|
package/src/anchor.js
CHANGED
|
@@ -209,6 +209,10 @@ export function anchorRect(node, options = {}) {
|
|
|
209
209
|
offset: logicalOffset = 2,
|
|
210
210
|
at,
|
|
211
211
|
alignTo,
|
|
212
|
+
// A popup that covers its own anchor — a native popup button's menu,
|
|
213
|
+
// which opens with the chosen row over the control — has no other side
|
|
214
|
+
// to flip to: it is clamped into the screen instead, as AppKit clamps.
|
|
215
|
+
flip = true,
|
|
212
216
|
direction = node.direction,
|
|
213
217
|
} = options;
|
|
214
218
|
const alignOffset = logicalAlignOffset * s;
|
|
@@ -278,6 +282,7 @@ export function anchorRect(node, options = {}) {
|
|
|
278
282
|
const below = ay + ah + offset;
|
|
279
283
|
const above = ay - height - offset;
|
|
280
284
|
if (
|
|
285
|
+
flip &&
|
|
281
286
|
side === 'bottom' &&
|
|
282
287
|
bottom != null &&
|
|
283
288
|
below + height > bottom &&
|
|
@@ -285,6 +290,7 @@ export function anchorRect(node, options = {}) {
|
|
|
285
290
|
) {
|
|
286
291
|
side = 'top';
|
|
287
292
|
} else if (
|
|
293
|
+
flip &&
|
|
288
294
|
side === 'top' &&
|
|
289
295
|
above < top &&
|
|
290
296
|
(bottom == null || below + height <= bottom)
|
package/src/appearance.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// What the desktop looks like, as four values an app can render from:
|
|
2
|
-
// light or dark, the accent colour
|
|
3
|
-
// less motion.
|
|
2
|
+
// light or dark, the accent colour (with the ink the desktop puts on it,
|
|
3
|
+
// where it names one), contrast, and whether the user asked for less motion.
|
|
4
4
|
//
|
|
5
5
|
// ## Why this is a ladder and not a call
|
|
6
6
|
//
|
|
@@ -77,6 +77,9 @@ const APPEARANCE_NS = 'org.freedesktop.appearance';
|
|
|
77
77
|
const NOTHING = Object.freeze({
|
|
78
78
|
colorScheme: 'no-preference',
|
|
79
79
|
accent: null,
|
|
80
|
+
accentText: null,
|
|
81
|
+
selection: null,
|
|
82
|
+
palette: null,
|
|
80
83
|
contrast: 'normal',
|
|
81
84
|
reducedMotion: false,
|
|
82
85
|
source: null,
|
|
@@ -106,9 +109,22 @@ const watchers = new Set();
|
|
|
106
109
|
// Publishing
|
|
107
110
|
// --------------------------------------------------------------------------
|
|
108
111
|
|
|
112
|
+
/** Two palettes with the same tokens — or both absent. */
|
|
113
|
+
function samePalette(a, b) {
|
|
114
|
+
if (a === b) return true;
|
|
115
|
+
if (!a || !b) return false;
|
|
116
|
+
const keys = Object.keys(a);
|
|
117
|
+
return (
|
|
118
|
+
keys.length === Object.keys(b).length && keys.every((k) => a[k] === b[k])
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
109
122
|
const SAME = (a, b) =>
|
|
110
123
|
a.colorScheme === b.colorScheme &&
|
|
111
124
|
a.accent === b.accent &&
|
|
125
|
+
a.accentText === b.accentText &&
|
|
126
|
+
a.selection === b.selection &&
|
|
127
|
+
samePalette(a.palette, b.palette) &&
|
|
112
128
|
a.contrast === b.contrast &&
|
|
113
129
|
a.reducedMotion === b.reducedMotion &&
|
|
114
130
|
a.source === b.source;
|
|
@@ -215,16 +231,40 @@ const SCHEMES = new Set(['light', 'dark', 'no-preference']);
|
|
|
215
231
|
* colour is the one thing worth being sure of.
|
|
216
232
|
*/
|
|
217
233
|
function sanitize(saved) {
|
|
234
|
+
// A string, checked as one: `RegExp.test` coerces, and `['#ed5b00']`
|
|
235
|
+
// would pass the pattern and then reach a style as an array.
|
|
236
|
+
const hex = (value) =>
|
|
237
|
+
typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) ? value : null;
|
|
218
238
|
return {
|
|
219
239
|
colorScheme: SCHEMES.has(saved?.colorScheme)
|
|
220
240
|
? saved.colorScheme
|
|
221
241
|
: 'no-preference',
|
|
222
|
-
accent:
|
|
242
|
+
accent: hex(saved?.accent),
|
|
243
|
+
accentText: hex(saved?.accentText),
|
|
244
|
+
selection: hex(saved?.selection),
|
|
245
|
+
palette: sanitizePalette(saved?.palette, hex),
|
|
223
246
|
contrast: saved?.contrast === 'high' ? 'high' : 'normal',
|
|
224
247
|
reducedMotion: saved?.reducedMotion === true,
|
|
225
248
|
};
|
|
226
249
|
}
|
|
227
250
|
|
|
251
|
+
/**
|
|
252
|
+
* A remembered palette is taken whole or not at all: every token named in
|
|
253
|
+
* `PALETTE_TOKENS` must be a colour, and nothing else is kept. A palette with
|
|
254
|
+
* a hole would merge over the built-in one and paint a built-in colour next
|
|
255
|
+
* to the desktop's, which is the one look this whole thing exists to avoid.
|
|
256
|
+
*/
|
|
257
|
+
function sanitizePalette(saved, hex) {
|
|
258
|
+
if (!saved || typeof saved !== 'object') return null;
|
|
259
|
+
const palette = {};
|
|
260
|
+
for (const token of PALETTE_TOKENS) {
|
|
261
|
+
const value = hex(saved[token]);
|
|
262
|
+
if (!value) return null;
|
|
263
|
+
palette[token] = value;
|
|
264
|
+
}
|
|
265
|
+
return Object.freeze(palette);
|
|
266
|
+
}
|
|
267
|
+
|
|
228
268
|
/**
|
|
229
269
|
* Seed the snapshot from disk. Once, synchronously, on the first read —
|
|
230
270
|
* never at import, so a process that does not ask what colour the desktop is
|
|
@@ -279,6 +319,9 @@ function save(values) {
|
|
|
279
319
|
v: CACHE_VERSION,
|
|
280
320
|
colorScheme: values.colorScheme,
|
|
281
321
|
accent: values.accent,
|
|
322
|
+
accentText: values.accentText,
|
|
323
|
+
selection: values.selection,
|
|
324
|
+
palette: values.palette,
|
|
282
325
|
contrast: values.contrast,
|
|
283
326
|
reducedMotion: values.reducedMotion,
|
|
284
327
|
}),
|
|
@@ -343,6 +386,12 @@ export function fromPortal(ns = {}) {
|
|
|
343
386
|
return {
|
|
344
387
|
colorScheme: schemeFromPortal(ns['color-scheme']),
|
|
345
388
|
accent: accentFromPortal(ns['accent-color']),
|
|
389
|
+
// The portal names the fill and nothing about what goes on it, nor a
|
|
390
|
+
// second shade for a selected row; the palette picks the legible ink
|
|
391
|
+
// itself (`resolveTheme`) and highlights with the accent.
|
|
392
|
+
accentText: null,
|
|
393
|
+
selection: null,
|
|
394
|
+
palette: null,
|
|
346
395
|
contrast: ns.contrast === 1 ? 'high' : 'normal',
|
|
347
396
|
// version 2 of the interface; on version 1 the key is simply absent and
|
|
348
397
|
// "no" is the right answer
|
|
@@ -473,6 +522,9 @@ export function fromXSettings(map) {
|
|
|
473
522
|
colorScheme: dark ? 'dark' : theme ? 'light' : 'no-preference',
|
|
474
523
|
// XSETTINGS has no accent colour. Not "none set" — no such key exists.
|
|
475
524
|
accent: null,
|
|
525
|
+
accentText: null,
|
|
526
|
+
selection: null,
|
|
527
|
+
palette: null,
|
|
476
528
|
contrast: high ? 'high' : 'normal',
|
|
477
529
|
reducedMotion: animations === 0,
|
|
478
530
|
};
|
|
@@ -509,42 +561,259 @@ async function xsettingsRung(app) {
|
|
|
509
561
|
* already resolved, and `NSWorkspace` answers the two accessibility flags
|
|
510
562
|
* directly.
|
|
511
563
|
*
|
|
564
|
+
* **The ink is read too.** `alternateSelectedControlTextColor` is what AppKit
|
|
565
|
+
* writes on a control filled with the accent, and it is not what a contrast
|
|
566
|
+
* ratio would choose: white on the orange accent is 2.6:1, and every native
|
|
567
|
+
* control does it anyway. A palette that follows the desktop's fill and
|
|
568
|
+
* picks its own letters for it looks like neither — so the pair travels
|
|
569
|
+
* together, and `accentText` is null from the sources that have no ink to
|
|
570
|
+
* name (the portal), where the palette decides by contrast.
|
|
571
|
+
*
|
|
572
|
+
* **And the selection shade.** A selected menu row or list row on macOS is
|
|
573
|
+
* not filled with the accent but with `selectedContentBackgroundColor`, a
|
|
574
|
+
* darker cut of it — same hue, lightness 0.54 → 0.40 in dark and 0.45 in
|
|
575
|
+
* light for the orange, hand-tuned per accent rather than computed. A menu
|
|
576
|
+
* highlighted in the raw accent beside a native one reads as too bright, so
|
|
577
|
+
* it is read rather than approximated, and is null where nothing names it.
|
|
578
|
+
*
|
|
579
|
+
* **One process reads the colours once.** `controlAccentColor` and the rest
|
|
580
|
+
* are resolved on first use and cached for the life of the process — after
|
|
581
|
+
* the user picks another accent, the same process still answers the old one,
|
|
582
|
+
* with or without an `NSApplication` (measured on macOS 15: the value does
|
|
583
|
+
* not move even after `NSSystemColorsDidChangeNotification`). So the program
|
|
584
|
+
* prints its values once and, on the change notifications, **exits**; the
|
|
585
|
+
* rung spawns another, whose first read is fresh. A watcher that re-read in
|
|
586
|
+
* place re-announced the same values, and nothing downstream ever saw a
|
|
587
|
+
* change — that was the bug.
|
|
588
|
+
*
|
|
589
|
+
* And it leaves when its parent has: eight of these were found on one
|
|
590
|
+
* machine, days old, reparented to launchd by apps that had died without
|
|
591
|
+
* running their exit handler.
|
|
592
|
+
*
|
|
512
593
|
* Exported so a test can pin the source; it cannot be executed on Linux.
|
|
513
594
|
*/
|
|
514
595
|
export const MACOS_PROGRAM = `
|
|
515
596
|
ObjC.import('AppKit');
|
|
516
597
|
var ud = $.NSUserDefaults.standardUserDefaults;
|
|
517
598
|
var ws = $.NSWorkspace.sharedWorkspace;
|
|
599
|
+
function srgb(color) {
|
|
600
|
+
var c = color.colorUsingColorSpace($.NSColorSpace.sRGBColorSpace);
|
|
601
|
+
return c.isNil() ? null
|
|
602
|
+
: [c.redComponent, c.greenComponent, c.blueComponent, c.alphaComponent];
|
|
603
|
+
}
|
|
518
604
|
function read() {
|
|
519
605
|
var style = ud.stringForKey('AppleInterfaceStyle');
|
|
606
|
+
var dark = !style.isNil() && ObjC.unwrap(style) === 'Dark';
|
|
520
607
|
var accent = null;
|
|
608
|
+
var accentText = null;
|
|
609
|
+
var selection = null;
|
|
521
610
|
try {
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
611
|
+
ObjC.import('stdlib');
|
|
612
|
+
// Dynamic colours resolve in the *current* appearance, which in a bare
|
|
613
|
+
// osascript is Aqua whatever the desktop is in; the ink AppKit puts on
|
|
614
|
+
// a filled control is allowed to differ between the two.
|
|
615
|
+
$.NSAppearance.setCurrentAppearance($.NSAppearance.appearanceNamed(
|
|
616
|
+
dark ? $.NSAppearanceNameDarkAqua : $.NSAppearanceNameAqua));
|
|
617
|
+
accent = srgb($.NSColor.controlAccentColor);
|
|
618
|
+
accentText = srgb($.NSColor.alternateSelectedControlTextColor);
|
|
619
|
+
selection = srgb($.NSColor.selectedContentBackgroundColor);
|
|
620
|
+
} catch (e) {}
|
|
621
|
+
// The rest of the desktop's palette: the semantic colours AppKit paints
|
|
622
|
+
// its own windows and controls with, in this appearance. Alpha is kept
|
|
623
|
+
// and composited on the other side, where the ground is known.
|
|
624
|
+
var colors = null;
|
|
625
|
+
try {
|
|
626
|
+
var C = $.NSColor;
|
|
627
|
+
var A = C.controlAccentColor;
|
|
628
|
+
var R = C.systemRedColor;
|
|
629
|
+
var rows = C.alternatingContentBackgroundColors;
|
|
630
|
+
colors = {
|
|
631
|
+
windowBackground: srgb(C.windowBackgroundColor),
|
|
632
|
+
controlBackground: srgb(C.controlBackgroundColor),
|
|
633
|
+
alternateRow: srgb(rows.objectAtIndex(rows.count > 1 ? 1 : 0)),
|
|
634
|
+
label: srgb(C.labelColor),
|
|
635
|
+
secondaryLabel: srgb(C.secondaryLabelColor),
|
|
636
|
+
separator: srgb(C.separatorColor),
|
|
637
|
+
focus: srgb(C.keyboardFocusIndicatorColor),
|
|
638
|
+
unemphasizedSelection: srgb(C.unemphasizedSelectedContentBackgroundColor),
|
|
639
|
+
accentPressed: srgb(A.colorWithSystemEffect($.NSColorSystemEffectPressed)),
|
|
640
|
+
accentDeepPressed: srgb(A.colorWithSystemEffect($.NSColorSystemEffectDeepPressed)),
|
|
641
|
+
textSelection: srgb(C.selectedTextBackgroundColor),
|
|
642
|
+
caret: srgb(C.textInsertionPointColor),
|
|
643
|
+
link: srgb(C.linkColor),
|
|
644
|
+
red: srgb(R),
|
|
645
|
+
redPressed: srgb(R.colorWithSystemEffect($.NSColorSystemEffectPressed)),
|
|
646
|
+
green: srgb(C.systemGreenColor),
|
|
647
|
+
orange: srgb(C.systemOrangeColor),
|
|
648
|
+
blue: srgb(C.systemBlueColor)
|
|
649
|
+
};
|
|
525
650
|
} catch (e) {}
|
|
526
651
|
return JSON.stringify({
|
|
527
|
-
dark:
|
|
652
|
+
dark: dark,
|
|
528
653
|
accent: accent,
|
|
654
|
+
accentText: accentText,
|
|
655
|
+
selection: selection,
|
|
656
|
+
colors: colors,
|
|
529
657
|
reducedMotion: !!ws.accessibilityDisplayShouldReduceMotion,
|
|
530
658
|
contrast: !!ws.accessibilityDisplayShouldIncreaseContrast
|
|
531
659
|
});
|
|
532
660
|
}
|
|
533
|
-
|
|
534
|
-
|
|
661
|
+
console.log(read());
|
|
662
|
+
// A change is answered by *leaving*: the parent spawns a fresh process and
|
|
663
|
+
// takes its first line. Reading again here would answer the old colours —
|
|
664
|
+
// see the comment above the program.
|
|
665
|
+
function changed() { $.exit(0); }
|
|
535
666
|
var dnc = $.NSDistributedNotificationCenter.defaultCenter;
|
|
536
667
|
['AppleInterfaceThemeChangedNotification',
|
|
537
668
|
'AppleColorPreferencesChangedNotification'].forEach(function (name) {
|
|
538
669
|
dnc.addObserverForNameObjectQueueUsingBlock(
|
|
539
|
-
name, $(), $.NSOperationQueue.mainQueue,
|
|
670
|
+
name, $(), $.NSOperationQueue.mainQueue, changed);
|
|
540
671
|
});
|
|
541
672
|
ws.notificationCenter.addObserverForNameObjectQueueUsingBlock(
|
|
542
673
|
'NSWorkspaceAccessibilityDisplayOptionsDidChangeNotification',
|
|
543
|
-
$(), $.NSOperationQueue.mainQueue,
|
|
674
|
+
$(), $.NSOperationQueue.mainQueue, changed);
|
|
675
|
+
// An app that dies without its exit handler (a signal, a crash) leaves this
|
|
676
|
+
// process behind, reparented to launchd, for as long as the machine is up.
|
|
677
|
+
ObjC.import('unistd');
|
|
678
|
+
$.NSTimer.scheduledTimerWithTimeIntervalRepeatsBlock(5, true, function () {
|
|
679
|
+
if ($.getppid() === 1) $.exit(0);
|
|
680
|
+
});
|
|
544
681
|
$.NSRunLoop.currentRunLoop.run();
|
|
545
682
|
`;
|
|
546
683
|
|
|
547
|
-
/**
|
|
684
|
+
/**
|
|
685
|
+
* The tokens a desktop palette names — the colour half of the built-in
|
|
686
|
+
* palette, less the inks `resolveTheme` derives by contrast. Both the cache
|
|
687
|
+
* and the macOS parser take a palette whole or not at all, and this is the
|
|
688
|
+
* list "whole" means.
|
|
689
|
+
*/
|
|
690
|
+
export const PALETTE_TOKENS = Object.freeze([
|
|
691
|
+
'background',
|
|
692
|
+
'surface',
|
|
693
|
+
'surfaceHover',
|
|
694
|
+
'text',
|
|
695
|
+
'textMuted',
|
|
696
|
+
'border',
|
|
697
|
+
'borderFocus',
|
|
698
|
+
'focusRing',
|
|
699
|
+
'track',
|
|
700
|
+
'accent',
|
|
701
|
+
'accentHover',
|
|
702
|
+
'accentActive',
|
|
703
|
+
'accentText',
|
|
704
|
+
'hoverBackground',
|
|
705
|
+
'hoverText',
|
|
706
|
+
'selection',
|
|
707
|
+
'caret',
|
|
708
|
+
'link',
|
|
709
|
+
'danger',
|
|
710
|
+
'dangerHover',
|
|
711
|
+
'success',
|
|
712
|
+
'warning',
|
|
713
|
+
'info',
|
|
714
|
+
]);
|
|
715
|
+
|
|
716
|
+
/** `[r, g, b]` or `[r, g, b, a]` in [0, 1], or null. */
|
|
717
|
+
function channels(value) {
|
|
718
|
+
if (!Array.isArray(value) || value.length < 3) return null;
|
|
719
|
+
const c = value.slice(0, 4);
|
|
720
|
+
if (c.length === 3) c.push(1);
|
|
721
|
+
return c.every((v) => typeof v === 'number' && v >= 0 && v <= 1) ? c : null;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const toHex = (c) =>
|
|
725
|
+
'#' +
|
|
726
|
+
c
|
|
727
|
+
.slice(0, 3)
|
|
728
|
+
.map((v) =>
|
|
729
|
+
Math.round(v * 255)
|
|
730
|
+
.toString(16)
|
|
731
|
+
.padStart(2, '0'),
|
|
732
|
+
)
|
|
733
|
+
.join('');
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* AppKit's semantic colours → react-x11's tokens, or null unless every one
|
|
737
|
+
* of them was read.
|
|
738
|
+
*
|
|
739
|
+
* **Alpha is composited here, over the ground it is drawn on.** AppKit's
|
|
740
|
+
* inks are translucent — `labelColor` is black at 85%, `separatorColor` at
|
|
741
|
+
* 10% — and every token in this renderer is a colour, so each is flattened
|
|
742
|
+
* over the window ground. The focus ring is drawn at 50% and gets the same
|
|
743
|
+
* treatment; a ring over a control is a shade off, and no one can see it.
|
|
744
|
+
*
|
|
745
|
+
* The rest is a naming exercise, with two decisions in it. The hover and
|
|
746
|
+
* pressed steps are AppKit's *pressed* and *deep-pressed* effects, because
|
|
747
|
+
* AppKit has no hover state for a filled control and its rollover effect
|
|
748
|
+
* is darker than its pressed one in light mode — a ramp that ran backwards.
|
|
749
|
+
* And `warning` is the system orange, not the yellow: a warning is read as
|
|
750
|
+
* letters too, and yellow on white is not.
|
|
751
|
+
*/
|
|
752
|
+
export function paletteFromMacOS(colors) {
|
|
753
|
+
if (!colors || typeof colors !== 'object') return null;
|
|
754
|
+
const read = {};
|
|
755
|
+
for (const name of [
|
|
756
|
+
'windowBackground',
|
|
757
|
+
'controlBackground',
|
|
758
|
+
'alternateRow',
|
|
759
|
+
'label',
|
|
760
|
+
'secondaryLabel',
|
|
761
|
+
'separator',
|
|
762
|
+
'focus',
|
|
763
|
+
'unemphasizedSelection',
|
|
764
|
+
'accentPressed',
|
|
765
|
+
'accentDeepPressed',
|
|
766
|
+
'textSelection',
|
|
767
|
+
'caret',
|
|
768
|
+
'link',
|
|
769
|
+
'red',
|
|
770
|
+
'redPressed',
|
|
771
|
+
'green',
|
|
772
|
+
'orange',
|
|
773
|
+
'blue',
|
|
774
|
+
]) {
|
|
775
|
+
const c = channels(colors[name]);
|
|
776
|
+
if (!c) return null;
|
|
777
|
+
read[name] = c;
|
|
778
|
+
}
|
|
779
|
+
const over = (c, ground) =>
|
|
780
|
+
toHex(ground.map((g, i) => c[i] * c[3] + g * (1 - c[3])));
|
|
781
|
+
const flat = (c) => toHex(c);
|
|
782
|
+
const ground = read.windowBackground;
|
|
783
|
+
const accent = colors.accent && channels(colors.accent);
|
|
784
|
+
const accentText = colors.accentText && channels(colors.accentText);
|
|
785
|
+
const selection = colors.selection && channels(colors.selection);
|
|
786
|
+
if (!accent || !accentText || !selection) return null;
|
|
787
|
+
const ink = flat(accentText);
|
|
788
|
+
const focus = over(read.focus, ground);
|
|
789
|
+
return Object.freeze({
|
|
790
|
+
background: flat(ground),
|
|
791
|
+
surface: flat(read.controlBackground),
|
|
792
|
+
surfaceHover: over(read.alternateRow, read.controlBackground),
|
|
793
|
+
text: over(read.label, ground),
|
|
794
|
+
textMuted: over(read.secondaryLabel, ground),
|
|
795
|
+
border: over(read.separator, ground),
|
|
796
|
+
borderFocus: focus,
|
|
797
|
+
focusRing: focus,
|
|
798
|
+
track: flat(read.unemphasizedSelection),
|
|
799
|
+
accent: flat(accent),
|
|
800
|
+
accentHover: flat(read.accentPressed),
|
|
801
|
+
accentActive: flat(read.accentDeepPressed),
|
|
802
|
+
accentText: ink,
|
|
803
|
+
hoverBackground: flat(selection),
|
|
804
|
+
hoverText: ink,
|
|
805
|
+
selection: flat(read.textSelection),
|
|
806
|
+
caret: flat(read.caret),
|
|
807
|
+
link: flat(read.link),
|
|
808
|
+
danger: flat(read.red),
|
|
809
|
+
dangerHover: flat(read.redPressed),
|
|
810
|
+
success: flat(read.green),
|
|
811
|
+
warning: flat(read.orange),
|
|
812
|
+
info: flat(read.blue),
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** One line of the child's output → the values, or null if it is noise. */
|
|
548
817
|
export function fromMacOS(line) {
|
|
549
818
|
let parsed;
|
|
550
819
|
try {
|
|
@@ -553,11 +822,25 @@ export function fromMacOS(line) {
|
|
|
553
822
|
return null;
|
|
554
823
|
}
|
|
555
824
|
if (!parsed || typeof parsed !== 'object') return null;
|
|
825
|
+
// The palette's accent family is the three values above it, so the parser
|
|
826
|
+
// sees them together
|
|
827
|
+
const colors = parsed.colors
|
|
828
|
+
? {
|
|
829
|
+
...parsed.colors,
|
|
830
|
+
accent: parsed.accent,
|
|
831
|
+
accentText: parsed.accentText,
|
|
832
|
+
selection: parsed.selection,
|
|
833
|
+
}
|
|
834
|
+
: null;
|
|
556
835
|
return {
|
|
557
836
|
// macOS always has a definite appearance, so an unset AppleInterfaceStyle
|
|
558
837
|
// is *light* rather than "no preference".
|
|
559
838
|
colorScheme: parsed.dark ? 'dark' : 'light',
|
|
560
839
|
accent: accentFromPortal(parsed.accent),
|
|
840
|
+
// The same `(r, g, b)` shape as the accent, by construction above
|
|
841
|
+
accentText: accentFromPortal(parsed.accentText),
|
|
842
|
+
selection: accentFromPortal(parsed.selection),
|
|
843
|
+
palette: paletteFromMacOS(colors),
|
|
561
844
|
contrast: parsed.contrast ? 'high' : 'normal',
|
|
562
845
|
reducedMotion: Boolean(parsed.reducedMotion),
|
|
563
846
|
};
|
|
@@ -565,36 +848,61 @@ export function fromMacOS(line) {
|
|
|
565
848
|
|
|
566
849
|
let child = null;
|
|
567
850
|
|
|
851
|
+
/** Set while this process is exiting, so a watcher's death is not answered. */
|
|
852
|
+
let closing = false;
|
|
853
|
+
|
|
568
854
|
/**
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
855
|
+
* Test seam, not public: what spawns the watcher. A fake here also lets the
|
|
856
|
+
* rung run off a Mac, where the real one cannot, so the respawn is tested on
|
|
857
|
+
* CI rather than on whoever has a Mac.
|
|
858
|
+
*/
|
|
859
|
+
let spawnWatcher = null;
|
|
860
|
+
export function _setMacOSSpawnForTests(fn) {
|
|
861
|
+
spawnWatcher = fn;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async function spawnProgram() {
|
|
865
|
+
if (spawnWatcher) return spawnWatcher();
|
|
866
|
+
const { spawn } = await import('node:child_process');
|
|
867
|
+
return spawn('osascript', ['-l', 'JavaScript', '-e', MACOS_PROGRAM], {
|
|
868
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** How long after an answered exit the next watcher starts. */
|
|
873
|
+
const RESPAWN_DELAY_MS = 150;
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Run one watcher process and resolve on its first usable line — or `false`
|
|
877
|
+
* if it dies, prints nothing usable, or takes more than a few seconds, any of
|
|
878
|
+
* which mean this Mac cannot answer and the ladder is finished.
|
|
879
|
+
*
|
|
880
|
+
* A watcher that exits *after* answering is a different thing: that is how
|
|
881
|
+
* the program says the desktop changed (see `MACOS_PROGRAM`), and the next
|
|
882
|
+
* one is started to read the new values. One that dies before answering is
|
|
883
|
+
* not replaced — that is osascript failing, and a respawn would loop on it.
|
|
572
884
|
*
|
|
573
885
|
* `console.log` in JXA has gone to stderr in some macOS releases and stdout in
|
|
574
886
|
* others, so both are read. It costs one extra listener to not depend on
|
|
575
887
|
* which.
|
|
576
888
|
*/
|
|
577
|
-
async function
|
|
578
|
-
if (process.platform !== 'darwin' || child) return false;
|
|
579
|
-
const { spawn } = await import('node:child_process');
|
|
580
|
-
|
|
889
|
+
async function runWatcher() {
|
|
581
890
|
let proc;
|
|
582
891
|
try {
|
|
583
|
-
proc =
|
|
584
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
585
|
-
});
|
|
892
|
+
proc = await spawnProgram();
|
|
586
893
|
} catch {
|
|
587
894
|
return false;
|
|
588
895
|
}
|
|
589
896
|
child = proc;
|
|
590
897
|
// Never a reason for the process to stay alive.
|
|
591
|
-
proc.unref();
|
|
898
|
+
proc.unref?.();
|
|
592
899
|
proc.stdout.unref?.();
|
|
593
900
|
proc.stderr.unref?.();
|
|
594
901
|
proc.on('error', () => {});
|
|
595
902
|
|
|
596
903
|
return await new Promise((resolve) => {
|
|
597
904
|
let settled = false;
|
|
905
|
+
let answered = false;
|
|
598
906
|
const done = (ok) => {
|
|
599
907
|
if (settled) return;
|
|
600
908
|
settled = true;
|
|
@@ -618,26 +926,41 @@ async function macosRung() {
|
|
|
618
926
|
const values = fromMacOS(line);
|
|
619
927
|
if (!values) continue;
|
|
620
928
|
if (settled && owner !== 'macos') return;
|
|
929
|
+
answered = true;
|
|
621
930
|
publish(values, 'macos');
|
|
622
931
|
done(true);
|
|
623
932
|
}
|
|
624
933
|
};
|
|
625
|
-
proc.stdout.setEncoding('utf8');
|
|
626
|
-
proc.stderr.setEncoding('utf8');
|
|
934
|
+
proc.stdout.setEncoding?.('utf8');
|
|
935
|
+
proc.stderr.setEncoding?.('utf8');
|
|
627
936
|
proc.stdout.on('data', onData);
|
|
628
937
|
proc.stderr.on('data', onData);
|
|
629
938
|
proc.on('exit', () => {
|
|
939
|
+
if (child === proc) child = null;
|
|
630
940
|
// Dying after it answered leaves the last value standing, which is more
|
|
631
|
-
// useful than reverting to the defaults
|
|
632
|
-
|
|
941
|
+
// useful than reverting to the defaults — and, while this rung owns the
|
|
942
|
+
// store, is the cue to read again.
|
|
633
943
|
done(false);
|
|
944
|
+
if (!answered || owner !== 'macos' || closing || child) return;
|
|
945
|
+
const again = setTimeout(() => {
|
|
946
|
+
if (owner === 'macos' && !closing && !child) void runWatcher();
|
|
947
|
+
}, RESPAWN_DELAY_MS);
|
|
948
|
+
again.unref?.();
|
|
634
949
|
});
|
|
635
950
|
});
|
|
636
951
|
}
|
|
637
952
|
|
|
953
|
+
async function macosRung() {
|
|
954
|
+
if ((process.platform !== 'darwin' && !spawnWatcher) || child) return false;
|
|
955
|
+
return runWatcher();
|
|
956
|
+
}
|
|
957
|
+
|
|
638
958
|
// Killed rather than left behind: `unref()` keeps it from holding *this*
|
|
639
959
|
// process open, and nothing keeps it from outliving it.
|
|
640
|
-
process.on('exit', () =>
|
|
960
|
+
process.on('exit', () => {
|
|
961
|
+
closing = true;
|
|
962
|
+
child?.kill();
|
|
963
|
+
});
|
|
641
964
|
|
|
642
965
|
// --------------------------------------------------------------------------
|
|
643
966
|
// The ladder
|
package/src/appearancehooks.js
CHANGED
|
@@ -69,6 +69,8 @@ export function useAppearanceWhen(enabled) {
|
|
|
69
69
|
* | --- | --- |
|
|
70
70
|
* | `colorScheme` | `'light'`, `'dark'` or `'no-preference'` |
|
|
71
71
|
* | `accent` | `'#ed5b00'`, or **null** — most backends do not implement it |
|
|
72
|
+
* | `accentText` | the ink the desktop puts on it — `'#ffffff'` on macOS — or **null** |
|
|
73
|
+
* | `selection` | the fill under a selected menu or list row — a darker cut of the accent on macOS — or **null** |
|
|
72
74
|
* | `contrast` | `'normal'` or `'high'` |
|
|
73
75
|
* | `reducedMotion` | `true` when the user asked for less animation |
|
|
74
76
|
* | `source` | `'portal'`, `'xsettings'`, `'macos'`, `'cache'`, or null |
|
|
@@ -88,8 +90,9 @@ export function useAppearanceWhen(enabled) {
|
|
|
88
90
|
* const [root] = await Promise.all([createRoot(), systemAppearance()]);
|
|
89
91
|
* ```
|
|
90
92
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
+
* The built-in palette already follows both the scheme and the accent, so
|
|
94
|
+
* an app that only wants to look like it belongs needs none of this — see
|
|
95
|
+
* `docs/appearance.md`.
|
|
93
96
|
*/
|
|
94
97
|
export function useSystemAppearance() {
|
|
95
98
|
return useAppearance(true);
|