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/src/components/Radio.js
CHANGED
|
@@ -5,7 +5,14 @@
|
|
|
5
5
|
import React, { useContext, useEffect, useMemo, useRef } from 'react';
|
|
6
6
|
import { useAppOrNull } from '../appcontext.js';
|
|
7
7
|
import { changeEvent } from './change.js';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
Bezel,
|
|
10
|
+
CONTROL_FONT_SIZE,
|
|
11
|
+
NO_ROW_RING,
|
|
12
|
+
bezelNatural,
|
|
13
|
+
nativeRingStyle,
|
|
14
|
+
useNativeControls,
|
|
15
|
+
} from './native.js';
|
|
9
16
|
import { focusRingStyle, labelContent, useControl, useTheme } from './theme.js';
|
|
10
17
|
import { XK_DOWN, XK_LEFT, XK_RIGHT, XK_UP } from './keys.js';
|
|
11
18
|
|
|
@@ -67,6 +74,7 @@ export function Radio({ value, children, label, disabled = false, native }) {
|
|
|
67
74
|
const {
|
|
68
75
|
hover,
|
|
69
76
|
focused,
|
|
77
|
+
focusVisible,
|
|
70
78
|
pressed,
|
|
71
79
|
props,
|
|
72
80
|
style: controlStyle,
|
|
@@ -97,6 +105,7 @@ export function Radio({ value, children, label, disabled = false, native }) {
|
|
|
97
105
|
style: [
|
|
98
106
|
controlStyle,
|
|
99
107
|
{ flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
108
|
+
NO_ROW_RING,
|
|
100
109
|
],
|
|
101
110
|
},
|
|
102
111
|
h(Bezel, {
|
|
@@ -107,11 +116,17 @@ export function Radio({ value, children, label, disabled = false, native }) {
|
|
|
107
116
|
style: {
|
|
108
117
|
width: nat.width,
|
|
109
118
|
height: nat.height,
|
|
110
|
-
|
|
119
|
+
// a circle: the ring rounds with it
|
|
120
|
+
...nativeRingStyle(theme, focusVisible, nat.height / 2),
|
|
111
121
|
},
|
|
112
122
|
}),
|
|
123
|
+
// At the control font size, as the cell's own title is: the bezel is
|
|
124
|
+
// designed beside 13pt, and the palette's 14px read a size too large
|
|
125
|
+
// next to it. Centred on the box, which is where AppKit centres it
|
|
126
|
+
// (measured: within a quarter point).
|
|
113
127
|
labelContent(children ?? label, {
|
|
114
128
|
color: disabled ? theme.textMuted : theme.text,
|
|
129
|
+
fontSize: CONTROL_FONT_SIZE.regular,
|
|
115
130
|
}),
|
|
116
131
|
);
|
|
117
132
|
}
|
package/src/components/Select.js
CHANGED
|
@@ -9,7 +9,12 @@ import { Icon } from './Icon.js';
|
|
|
9
9
|
import {
|
|
10
10
|
ABS_FILL,
|
|
11
11
|
Bezel,
|
|
12
|
+
NATIVE_MENU,
|
|
13
|
+
NATIVE_RING,
|
|
14
|
+
TITLE_BASELINE,
|
|
12
15
|
bezelNatural,
|
|
16
|
+
bezelShadow,
|
|
17
|
+
nativeTitleStyle,
|
|
13
18
|
pressWash,
|
|
14
19
|
useNativeControls,
|
|
15
20
|
} from './native.js';
|
|
@@ -52,6 +57,51 @@ const ITEM_PAD_RIGHT = ITEM_PAD;
|
|
|
52
57
|
// sized against 14 under a theme that set 18 is a row its own label does not
|
|
53
58
|
// fit in.
|
|
54
59
|
const itemHeight = (fontSize) => capBand(fontSize) + ITEM_PAD * 2;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The menu's geometry: the palette's, or — where the trigger is a native
|
|
63
|
+
* popup bezel — NSMenu's, so the sheet that drops from it is the one the
|
|
64
|
+
* bezel promises. The drawn menu marks the chosen option in bold; a native
|
|
65
|
+
* popup's menu marks it with a check in the column every row reserves, at
|
|
66
|
+
* regular weight, as the bezel's own menu does.
|
|
67
|
+
*/
|
|
68
|
+
function menuMetrics(theme, native) {
|
|
69
|
+
if (native) {
|
|
70
|
+
const padLeft = NATIVE_MENU.padLeft + NATIVE_MENU.markColumn;
|
|
71
|
+
const cap = capBand(NATIVE_MENU.fontSize);
|
|
72
|
+
return {
|
|
73
|
+
...NATIVE_MENU,
|
|
74
|
+
padLeft,
|
|
75
|
+
selectedWeight: 'normal',
|
|
76
|
+
check: true,
|
|
77
|
+
// Where a row's capitals end, from the row's top: the centred band,
|
|
78
|
+
// at the whole pixel. Placed there by the row rather than centred by
|
|
79
|
+
// layout — centring puts the band at 6.5pt, which floors to 6 at 1×
|
|
80
|
+
// and is exact at 2×, and the menu that opens over the trigger is
|
|
81
|
+
// aligned on this number, so it has to be the same one at every
|
|
82
|
+
// scale.
|
|
83
|
+
capsBottom: Math.floor((NATIVE_MENU.row - cap) / 2) + cap,
|
|
84
|
+
// How far the menu hangs past the trigger: left, so the titles line
|
|
85
|
+
// up (the menu's inset less the trigger's); right, past the arrow
|
|
86
|
+
// capsule, read off the screen at 10pt.
|
|
87
|
+
hang: {
|
|
88
|
+
left: MENU_BORDER + NATIVE_MENU.pad + padLeft - TRIGGER_PAD_LEFT,
|
|
89
|
+
right: 10,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
fontSize: theme.fontSize,
|
|
95
|
+
weight: 'normal',
|
|
96
|
+
selectedWeight: 'bold',
|
|
97
|
+
check: false,
|
|
98
|
+
row: itemHeight(theme.fontSize),
|
|
99
|
+
pad: MENU_PAD,
|
|
100
|
+
padLeft: ITEM_PAD_LEFT,
|
|
101
|
+
padRight: ITEM_PAD_RIGHT,
|
|
102
|
+
radius: rowRadius(theme, MENU_BORDER, MENU_PAD),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
55
105
|
// The scrolling pane's own padding, and so the inset between the edge
|
|
56
106
|
// and an option — which is what makes the highlight read as a pill on the
|
|
57
107
|
// menu rather than a band across it, and what the pill's own corner radius
|
|
@@ -63,6 +113,10 @@ const MENU_PAD = 4;
|
|
|
63
113
|
// where it meets the desktop behind it, and a theme that draws 2px borders
|
|
64
114
|
// on its *controls* does not mean a 2px outline around every popup.
|
|
65
115
|
const MENU_BORDER = 1;
|
|
116
|
+
// Where a native popup bezel's title starts, from the trigger's left edge —
|
|
117
|
+
// and so what the menu's own inset is measured against when it opens over
|
|
118
|
+
// the trigger (`menuAnchorOptions`).
|
|
119
|
+
const TRIGGER_PAD_LEFT = 10;
|
|
66
120
|
// the scrollbar is drawn *over* the content rather than insetting it
|
|
67
121
|
// (`nodes.js`, SCROLLBAR_WIDTH), so a menu that scrolls reserves the room
|
|
68
122
|
const SCROLLBAR_WIDTH = 6;
|
|
@@ -88,23 +142,28 @@ function normalizeOption(option) {
|
|
|
88
142
|
* than the screen it has to open on — `anchorRect` can slide a popup left to
|
|
89
143
|
* fit, but nothing can rescue one that is wider than the display.
|
|
90
144
|
*/
|
|
91
|
-
function menuWidth(node, options, value, scrolls,
|
|
145
|
+
function menuWidth(node, options, value, scrolls, metrics) {
|
|
92
146
|
let widest = 0;
|
|
93
147
|
for (const option of options) {
|
|
94
148
|
const { width } = measureLabel(node, option.label, {
|
|
95
|
-
size: fontSize,
|
|
96
|
-
weight: option.value === value ?
|
|
149
|
+
size: metrics.fontSize,
|
|
150
|
+
weight: option.value === value ? metrics.selectedWeight : 'normal',
|
|
97
151
|
});
|
|
98
152
|
if (width > widest) widest = width;
|
|
99
153
|
}
|
|
100
154
|
const chrome =
|
|
101
|
-
(MENU_BORDER +
|
|
102
|
-
|
|
103
|
-
|
|
155
|
+
(MENU_BORDER + metrics.pad) * 2 +
|
|
156
|
+
metrics.padLeft +
|
|
157
|
+
metrics.padRight +
|
|
104
158
|
(scrolls ? SCROLLBAR_WIDTH : 0);
|
|
105
159
|
// logical throughout: the trigger's rect divided out of device pixels,
|
|
106
160
|
// the measured labels already logical (measureLabel), the area logical
|
|
107
|
-
|
|
161
|
+
// A native popup's menu opens over its control and covers all of it,
|
|
162
|
+
// the arrow capsule included — it hangs out on both sides (`hang`).
|
|
163
|
+
const trigger =
|
|
164
|
+
node.abs.width / node.scale +
|
|
165
|
+
(metrics.hang?.left ?? 0) +
|
|
166
|
+
(metrics.hang?.right ?? 0);
|
|
108
167
|
const width = Math.max(trigger, Math.ceil(widest) + chrome);
|
|
109
168
|
const area = anchorArea(node);
|
|
110
169
|
return area ? Math.min(width, area.width) : width;
|
|
@@ -119,6 +178,7 @@ function Option({
|
|
|
119
178
|
nodeRef,
|
|
120
179
|
posinset,
|
|
121
180
|
setsize,
|
|
181
|
+
metrics,
|
|
122
182
|
}) {
|
|
123
183
|
const theme = useTheme();
|
|
124
184
|
// one highlight, shared by pointer and keyboard: hovering moves the
|
|
@@ -135,20 +195,34 @@ function Option({
|
|
|
135
195
|
onMouseEnter: () => onHover?.(),
|
|
136
196
|
onClick: () => onPick(option),
|
|
137
197
|
style: {
|
|
138
|
-
height:
|
|
139
|
-
justifyContent: 'center',
|
|
140
|
-
|
|
141
|
-
|
|
198
|
+
height: metrics.row,
|
|
199
|
+
justifyContent: metrics.capsBottom ? 'flex-end' : 'center',
|
|
200
|
+
paddingBottom: metrics.capsBottom
|
|
201
|
+
? metrics.row - metrics.capsBottom
|
|
202
|
+
: 0,
|
|
203
|
+
paddingLeft: metrics.padLeft,
|
|
204
|
+
paddingRight: metrics.padRight,
|
|
142
205
|
// the menus' pill, for the same reason and by the same rule: an
|
|
143
206
|
// option list and a menu are one surface with rows in it, and two
|
|
144
207
|
// shapes for that would only say the widgets were written apart
|
|
145
|
-
borderRadius:
|
|
208
|
+
borderRadius: metrics.radius,
|
|
146
209
|
// nothing at rest — the sheet under it is already that colour, and a
|
|
147
210
|
// rounded fill of the same colour is a coverage mask drawn to change
|
|
148
211
|
// nothing, with four corners it deliberately leaves out
|
|
149
212
|
backgroundColor: active ? theme.hoverBackground : 'transparent',
|
|
150
213
|
},
|
|
151
214
|
},
|
|
215
|
+
// The native menu's check, in the mark column to the left of the title.
|
|
216
|
+
// Drawn over the row's own left padding — NSMenu's mark sits inside the
|
|
217
|
+
// highlight, between its edge and the title.
|
|
218
|
+
metrics.check &&
|
|
219
|
+
selected &&
|
|
220
|
+
h(Icon, {
|
|
221
|
+
name: 'check',
|
|
222
|
+
size: capBand(metrics.fontSize),
|
|
223
|
+
color: active ? theme.hoverText : theme.text,
|
|
224
|
+
style: { position: 'absolute', left: metrics.markLeft },
|
|
225
|
+
}),
|
|
152
226
|
h(
|
|
153
227
|
'text',
|
|
154
228
|
{
|
|
@@ -156,7 +230,8 @@ function Option({
|
|
|
156
230
|
capTrim,
|
|
157
231
|
{
|
|
158
232
|
color: active ? theme.hoverText : theme.text,
|
|
159
|
-
|
|
233
|
+
fontSize: metrics.fontSize,
|
|
234
|
+
fontWeight: selected ? metrics.selectedWeight : metrics.weight,
|
|
160
235
|
},
|
|
161
236
|
],
|
|
162
237
|
},
|
|
@@ -212,27 +287,61 @@ export function Select({
|
|
|
212
287
|
|
|
213
288
|
const normalized = options.map(normalizeOption);
|
|
214
289
|
const current = normalized.find((o) => o.value === value);
|
|
215
|
-
// Rows are sized from the
|
|
216
|
-
// the
|
|
217
|
-
const
|
|
218
|
-
const
|
|
290
|
+
// Rows are sized from the text size the labels inside them come out at:
|
|
291
|
+
// the palette's, or NSMenu's under a native popup bezel.
|
|
292
|
+
const metrics = menuMetrics(theme, nativeControls);
|
|
293
|
+
const rowHeight = metrics.row;
|
|
294
|
+
const contentHeight = normalized.length * rowHeight + metrics.pad * 2;
|
|
219
295
|
const menuHeight = Math.min(contentHeight, MAX_MENU_HEIGHT);
|
|
220
296
|
const scrolls = contentHeight > MAX_MENU_HEIGHT;
|
|
221
297
|
|
|
222
298
|
// shared anchoring: also flips the menu above the trigger when there is
|
|
223
299
|
// no room below, and slides it left when the menu is wider than the
|
|
224
300
|
// trigger and would otherwise open off the right edge
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
301
|
+
// A native popup button does not drop its menu below the control: it
|
|
302
|
+
// opens the menu *over* it, with the chosen row on top of the control and
|
|
303
|
+
// the two titles coincident — the menu's mark column hanging out to the
|
|
304
|
+
// left, the rows above and below it stacked up and down. The menu's
|
|
305
|
+
// title inset less the trigger's is how far left it starts. No flip: a
|
|
306
|
+
// menu over its control has no other side, and is clamped into the
|
|
307
|
+
// screen as AppKit's is.
|
|
308
|
+
const menuAnchorOptions = () => {
|
|
309
|
+
const height = menuHeight + 2;
|
|
310
|
+
const width = menuWidth(
|
|
229
311
|
triggerRef.current,
|
|
230
312
|
normalized,
|
|
231
313
|
value,
|
|
232
314
|
scrolls,
|
|
233
|
-
|
|
234
|
-
)
|
|
235
|
-
|
|
315
|
+
metrics,
|
|
316
|
+
);
|
|
317
|
+
if (!nativeControls) return { placement: 'bottom', height, width };
|
|
318
|
+
const node = triggerRef.current;
|
|
319
|
+
const scale = node?.scale ?? 1;
|
|
320
|
+
const triggerHeight = node ? node.abs.height / scale : 0;
|
|
321
|
+
const shadow = bezelShadow(app, 'popup');
|
|
322
|
+
// Aligned on the *capitals*, which is what the eye lines up: the
|
|
323
|
+
// trigger's sit on the title baseline (`TITLE_BASELINE` above the bezel
|
|
324
|
+
// body's bottom), a row's on `capsBottom` — both whole pixels, both
|
|
325
|
+
// placed rather than centred, so the two agree at every scale.
|
|
326
|
+
const triggerCapsBottom =
|
|
327
|
+
triggerHeight - shadow.bottom - TITLE_BASELINE.regular;
|
|
328
|
+
const rowCapsBottom = metrics.capsBottom;
|
|
329
|
+
const chosen = Math.max(
|
|
330
|
+
0,
|
|
331
|
+
normalized.findIndex((o) => o.value === value),
|
|
332
|
+
);
|
|
333
|
+
const chosenRowTop = MENU_BORDER + metrics.pad + chosen * metrics.row;
|
|
334
|
+
const top = triggerCapsBottom - chosenRowTop - rowCapsBottom;
|
|
335
|
+
return {
|
|
336
|
+
placement: 'bottom',
|
|
337
|
+
offset: 0,
|
|
338
|
+
flip: false,
|
|
339
|
+
at: { x: 0, y: top, width: node ? node.abs.width / scale : 0, height: 0 },
|
|
340
|
+
alignOffset: -metrics.hang.left,
|
|
341
|
+
height,
|
|
342
|
+
width,
|
|
343
|
+
};
|
|
344
|
+
};
|
|
236
345
|
|
|
237
346
|
const close = () => setOpen(false);
|
|
238
347
|
const openMenu = () => {
|
|
@@ -357,8 +466,20 @@ export function Select({
|
|
|
357
466
|
alignItems: 'center',
|
|
358
467
|
gap: 8,
|
|
359
468
|
height: bezelNatural(app, 'popup').height,
|
|
360
|
-
paddingLeft:
|
|
469
|
+
paddingLeft: TRIGGER_PAD_LEFT,
|
|
361
470
|
paddingRight: 26,
|
|
471
|
+
// The title sits where NSPopUpButtonCell puts it — on the
|
|
472
|
+
// same baseline as a push button's, above the body's bottom
|
|
473
|
+
// edge, the shadow padded off (see Button).
|
|
474
|
+
paddingTop: bezelShadow(app, 'popup').top,
|
|
475
|
+
paddingBottom:
|
|
476
|
+
bezelShadow(app, 'popup').bottom + TITLE_BASELINE.regular,
|
|
477
|
+
// the keyboard ring hugs the bezel's corners (see Button)
|
|
478
|
+
borderRadius: 6,
|
|
479
|
+
':focus-visible': {
|
|
480
|
+
outlineWidth: NATIVE_RING.width,
|
|
481
|
+
outlineOffset: NATIVE_RING.offset,
|
|
482
|
+
},
|
|
362
483
|
}
|
|
363
484
|
: {
|
|
364
485
|
cursor: 'pointer',
|
|
@@ -409,7 +530,13 @@ export function Select({
|
|
|
409
530
|
}),
|
|
410
531
|
h(
|
|
411
532
|
'text',
|
|
412
|
-
{
|
|
533
|
+
{
|
|
534
|
+
style: [
|
|
535
|
+
capTrim,
|
|
536
|
+
nativeControls && nativeTitleStyle('regular'),
|
|
537
|
+
{ color: current ? theme.text : theme.textMuted },
|
|
538
|
+
],
|
|
539
|
+
},
|
|
413
540
|
current ? current.label : placeholder,
|
|
414
541
|
),
|
|
415
542
|
h('box', { style: { flexGrow: 1 } }),
|
|
@@ -484,7 +611,11 @@ export function Select({
|
|
|
484
611
|
// for a Select; this pane is scrolled by wheel and by the
|
|
485
612
|
// arrow keys the trigger already handles.
|
|
486
613
|
focusable: false,
|
|
487
|
-
style: {
|
|
614
|
+
style: {
|
|
615
|
+
flexGrow: 1,
|
|
616
|
+
padding: metrics.pad,
|
|
617
|
+
overflow: 'scroll',
|
|
618
|
+
},
|
|
488
619
|
},
|
|
489
620
|
normalized.map((option, index) =>
|
|
490
621
|
h(Option, {
|
|
@@ -497,6 +628,7 @@ export function Select({
|
|
|
497
628
|
nodeRef: index === activeIndex ? activeRef : undefined,
|
|
498
629
|
onHover: () => setActiveIndex(index),
|
|
499
630
|
onPick: pick,
|
|
631
|
+
metrics,
|
|
500
632
|
}),
|
|
501
633
|
),
|
|
502
634
|
),
|
package/src/components/Switch.js
CHANGED
|
@@ -9,6 +9,8 @@ import { changeEvent } from './change.js';
|
|
|
9
9
|
import {
|
|
10
10
|
ABS_FILL,
|
|
11
11
|
Bezel,
|
|
12
|
+
NO_ROW_RING,
|
|
13
|
+
nativeRingStyle,
|
|
12
14
|
bezelNatural,
|
|
13
15
|
pressWash,
|
|
14
16
|
useNativeControls,
|
|
@@ -116,6 +118,7 @@ export function Switch({
|
|
|
116
118
|
height: nat.height,
|
|
117
119
|
hitSlop: Math.max(0, (24 - nat.height) / 2),
|
|
118
120
|
},
|
|
121
|
+
NO_ROW_RING,
|
|
119
122
|
style,
|
|
120
123
|
],
|
|
121
124
|
},
|
|
@@ -123,7 +126,11 @@ export function Switch({
|
|
|
123
126
|
kind: 'switch',
|
|
124
127
|
state: checked ? 1 : 0,
|
|
125
128
|
enabled: !disabled,
|
|
126
|
-
style:
|
|
129
|
+
style: [
|
|
130
|
+
ABS_FILL,
|
|
131
|
+
// the pill: the ring rounds with it
|
|
132
|
+
nativeRingStyle(theme, control.focusVisible, nat.height / 2),
|
|
133
|
+
],
|
|
127
134
|
}),
|
|
128
135
|
h('box', {
|
|
129
136
|
style: [
|
package/src/components/native.js
CHANGED
|
@@ -58,6 +58,105 @@ export function bezelNatural(app, kind, controlSize = 'regular') {
|
|
|
58
58
|
return app?.nativeBezels?.natural(kind, controlSize) ?? null;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The translucent rows above and below the bezel's solid body — a push
|
|
63
|
+
* button's shadow — as padding, so a label centred in the box is centred in
|
|
64
|
+
* the control rather than in its footprint. Zero where there is no store.
|
|
65
|
+
*/
|
|
66
|
+
export function bezelShadow(app, kind, controlSize = 'regular') {
|
|
67
|
+
return (
|
|
68
|
+
app?.nativeBezels?.shadow?.(kind, controlSize) ?? { top: 0, bottom: 0 }
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Where NSButtonCell — and NSPopUpButtonCell — put a title: its baseline
|
|
74
|
+
* this far above the bezel body's bottom edge, at each control size. Not
|
|
75
|
+
* centred: the capitals ride a little above the middle and the descenders
|
|
76
|
+
* hang below it. Measured off the cells' own rendering of "Done" and "Blue"
|
|
77
|
+
* (macOS 15): the capitals sit 4.5pt below the top of a 20pt regular body
|
|
78
|
+
* and 6pt above its bottom, 4pt and 4pt in a 16pt small one.
|
|
79
|
+
*/
|
|
80
|
+
export const TITLE_BASELINE = Object.freeze({ regular: 6, small: 4 });
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The system control font at each size — `NSFont.systemFontSize` and
|
|
84
|
+
* `smallSystemFontSize` — which every bezel is designed around and the
|
|
85
|
+
* baseline above was measured with. A native label is set in it rather than
|
|
86
|
+
* in the palette's size: a 14px title in a small bezel has no room to sit
|
|
87
|
+
* where the cell's does.
|
|
88
|
+
*/
|
|
89
|
+
export const CONTROL_FONT_SIZE = Object.freeze({ regular: 13, small: 11 });
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The style of a title placed the way a cell places it: on the bottom of a
|
|
93
|
+
* content box padded by `TITLE_BASELINE` (a cap-trimmed label's bottom edge
|
|
94
|
+
* is its baseline, and the trim rounds the cap height to a whole pixel, so
|
|
95
|
+
* the baseline lands on one), at the control font size. Only the label goes
|
|
96
|
+
* on the baseline: an icon beside it stays centred, as AppKit centres an
|
|
97
|
+
* image. Not sized to the cap band: a box that tall clips the ascenders —
|
|
98
|
+
* the l in "Blue" lost its top row.
|
|
99
|
+
*/
|
|
100
|
+
export function nativeTitleStyle(controlSize = 'regular') {
|
|
101
|
+
return { alignSelf: 'flex-end', fontSize: CONTROL_FONT_SIZE[controlSize] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* NSMenu's geometry, for the menu a native popup bezel opens — read off
|
|
106
|
+
* `NSMenu.size` on macOS 15 rather than off a screenshot, so it is the
|
|
107
|
+
* menu's own arithmetic: a row is 22pt, the sheet pads 5pt top and bottom,
|
|
108
|
+
* a separator is 11pt, and the font is the 13pt menu font at regular
|
|
109
|
+
* weight. A title starts 13pt in from the edge — 5 to the highlight, 8
|
|
110
|
+
* inside it, which is the mark column a menu always reserves — and 28.5pt
|
|
111
|
+
* of chrome stand between a title's width and the menu's. The highlight's
|
|
112
|
+
* inset is the pad; its radius is the one number here read off the screen.
|
|
113
|
+
*/
|
|
114
|
+
export const NATIVE_MENU = Object.freeze({
|
|
115
|
+
fontSize: 13,
|
|
116
|
+
weight: 'normal',
|
|
117
|
+
row: 22,
|
|
118
|
+
pad: 5,
|
|
119
|
+
padLeft: 8,
|
|
120
|
+
padRight: 10,
|
|
121
|
+
radius: 5,
|
|
122
|
+
separator: 11,
|
|
123
|
+
// A popup button's menu reserves a column for the check that marks the
|
|
124
|
+
// chosen item, and its titles start that much further in: 38pt of chrome
|
|
125
|
+
// against the plain menu's 28.5 (`NSPopUpButton.menu.size`). The mark
|
|
126
|
+
// itself sits 11pt in from the edge, which is 5 inside the row.
|
|
127
|
+
markColumn: 9,
|
|
128
|
+
markLeft: 5,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* AppKit's focus ring: one band, outside the control, following the
|
|
133
|
+
* control's own shape — a circle round a radio, a rounded square round a
|
|
134
|
+
* checkbox, the bezel's corners round a button. Never round the label, and
|
|
135
|
+
* never two: a native row that lit its keyboard ring round the whole row
|
|
136
|
+
* *and* the part's ring round the bezel showed a rectangle round the label
|
|
137
|
+
* beside a rectangle round the control. Width read off the screen (3pt,
|
|
138
|
+
* hugging the edge); the colour is the palette's ring, which on macOS is
|
|
139
|
+
* `keyboardFocusIndicatorColor` over the ground.
|
|
140
|
+
*
|
|
141
|
+
* Only for focus that shows: a click on a checkbox lights no ring in
|
|
142
|
+
* AppKit, Tab does — which is `useControl().focusVisible`.
|
|
143
|
+
*/
|
|
144
|
+
export const NATIVE_RING = Object.freeze({ width: 3, offset: 0 });
|
|
145
|
+
|
|
146
|
+
/** The ring, as style for the bezel node, shaped to `radius`. */
|
|
147
|
+
export function nativeRingStyle(theme, on, radius) {
|
|
148
|
+
if (!on) return null;
|
|
149
|
+
return {
|
|
150
|
+
outlineWidth: NATIVE_RING.width,
|
|
151
|
+
outlineColor: theme.focusRing,
|
|
152
|
+
outlineOffset: NATIVE_RING.offset,
|
|
153
|
+
borderRadius: radius,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The row round a native part opts out of the renderer's own ring. */
|
|
158
|
+
export const NO_ROW_RING = Object.freeze({ outlineWidth: 0 });
|
|
159
|
+
|
|
61
160
|
/** Absolute fill inside the control's box — where every bezel layer goes. */
|
|
62
161
|
export const ABS_FILL = Object.freeze({
|
|
63
162
|
position: 'absolute',
|
package/src/components/theme.js
CHANGED
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
import React, { useContext, useMemo, useState } from 'react';
|
|
11
11
|
import { useAppearanceWhen } from '../appearancehooks.js';
|
|
12
12
|
import { EnvValue, registerFrameProvider } from '../frame/env.js';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
DarkTheme,
|
|
15
|
+
DefaultTheme,
|
|
16
|
+
paletteFor,
|
|
17
|
+
resolveTheme,
|
|
18
|
+
} from '../palette.js';
|
|
14
19
|
|
|
15
20
|
const h = React.createElement;
|
|
16
21
|
|
|
@@ -67,14 +72,15 @@ const FILL = Object.freeze({ flexGrow: 1 });
|
|
|
67
72
|
* <ThemeProvider value={light} dark={{ background: '#101418' }}>
|
|
68
73
|
* ```
|
|
69
74
|
*
|
|
70
|
-
* The desktop's **accent** is
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
75
|
+
* The desktop's **accent** is part of what a following provider follows: the
|
|
76
|
+
* base under `value` is the desktop's palette, accent and all, where the
|
|
77
|
+
* desktop reports one (`paletteFor`). A brand keeps its own colour by naming
|
|
78
|
+
* it — `value` wins over the base token for token — and a pinned
|
|
79
|
+
* `colorScheme` follows nothing, the accent included:
|
|
74
80
|
*
|
|
75
81
|
* ```jsx
|
|
76
|
-
*
|
|
77
|
-
* <ThemeProvider value={
|
|
82
|
+
* <ThemeProvider value={{ accent: '#e17055', accentHover: '#c0563a' }}> // brand accent, desktop scheme
|
|
83
|
+
* <ThemeProvider value={brand} colorScheme="light"> // neither
|
|
78
84
|
* ```
|
|
79
85
|
*/
|
|
80
86
|
export function ThemeProvider({
|
|
@@ -93,16 +99,17 @@ export function ThemeProvider({
|
|
|
93
99
|
const system = useAppearanceWhen(follows);
|
|
94
100
|
const wantsDark =
|
|
95
101
|
colorScheme === 'dark' || (follows && system.colorScheme === 'dark');
|
|
102
|
+
// An outer provider is the base; with none, the base is the desktop's
|
|
103
|
+
// palette when this provider follows it and the scheme's own built-in one
|
|
104
|
+
// when pinned. So `value` names what this app changes and everything else
|
|
105
|
+
// keeps following the desktop. `paletteFor` answers with one object per
|
|
106
|
+
// desktop answer, so the memo below holds across renders.
|
|
107
|
+
const base =
|
|
108
|
+
outer ??
|
|
109
|
+
(follows ? paletteFor(system) : wantsDark ? DarkTheme : DefaultTheme);
|
|
96
110
|
const theme = useMemo(
|
|
97
|
-
() =>
|
|
98
|
-
|
|
99
|
-
// own built-in palette. So `value` names what this app changes and
|
|
100
|
-
// everything else keeps following the desktop.
|
|
101
|
-
resolveTheme(
|
|
102
|
-
dark && wantsDark ? { ...value, ...dark } : value,
|
|
103
|
-
outer ?? (wantsDark ? DarkTheme : DefaultTheme),
|
|
104
|
-
),
|
|
105
|
-
[outer, value, dark, wantsDark],
|
|
111
|
+
() => resolveTheme(dark && wantsDark ? { ...value, ...dark } : value, base),
|
|
112
|
+
[base, value, dark, wantsDark],
|
|
106
113
|
);
|
|
107
114
|
// A provider that **names** a direction plants it as a style too, so the
|
|
108
115
|
// node tree mirrors along with the widgets: `useTheme().direction` is what
|
|
@@ -181,14 +188,14 @@ function planted(children, theme, style) {
|
|
|
181
188
|
*
|
|
182
189
|
* Identity matters: widgets plant what this returns on their own root node,
|
|
183
190
|
* and a fresh object every render would re-resolve every `$token` beneath it
|
|
184
|
-
* and defeat the resolution cache.
|
|
185
|
-
*
|
|
191
|
+
* and defeat the resolution cache. `paletteFor` answers with one object per
|
|
192
|
+
* desktop answer, so the unprovided answer is stable too.
|
|
186
193
|
*/
|
|
187
194
|
export function useTheme() {
|
|
188
195
|
const provided = useContext(ThemeContext);
|
|
189
196
|
const system = useAppearanceWhen(provided == null);
|
|
190
197
|
if (provided) return provided;
|
|
191
|
-
return system
|
|
198
|
+
return paletteFor(system);
|
|
192
199
|
}
|
|
193
200
|
|
|
194
201
|
/**
|
|
@@ -240,6 +247,11 @@ export function useDirection() {
|
|
|
240
247
|
export function useControl(disabled, onActivate, { styled = false } = {}) {
|
|
241
248
|
const [hover, setHover] = useState(false);
|
|
242
249
|
const [focused, setFocused] = useState(false);
|
|
250
|
+
// …and whether that focus is the kind that shows: the `:focus-visible`
|
|
251
|
+
// the focus manager has already decided by the time `onFocus` fires
|
|
252
|
+
// (Tab lights it, a click does not, except into a text control). A
|
|
253
|
+
// native control draws its ring for this and not for `focused`.
|
|
254
|
+
const [focusVisible, setFocusVisible] = useState(false);
|
|
243
255
|
const [pressed, setPressed] = useState(false);
|
|
244
256
|
const activation = {
|
|
245
257
|
focusable: true,
|
|
@@ -275,15 +287,20 @@ export function useControl(disabled, onActivate, { styled = false } = {}) {
|
|
|
275
287
|
},
|
|
276
288
|
onMouseDown: () => setPressed(true),
|
|
277
289
|
onMouseUp: () => setPressed(false),
|
|
278
|
-
onFocus: () =>
|
|
290
|
+
onFocus: (ev) => {
|
|
291
|
+
setFocused(true);
|
|
292
|
+
setFocusVisible(ev?.target?.states?.[':focus-visible'] === true);
|
|
293
|
+
},
|
|
279
294
|
onBlur: () => {
|
|
280
295
|
setFocused(false);
|
|
296
|
+
setFocusVisible(false);
|
|
281
297
|
setPressed(false);
|
|
282
298
|
},
|
|
283
299
|
};
|
|
284
300
|
return {
|
|
285
301
|
hover: !styled && hover && !disabled,
|
|
286
302
|
focused: !styled && focused && !disabled,
|
|
303
|
+
focusVisible: !styled && focusVisible && !disabled,
|
|
287
304
|
pressed: !styled && pressed && !disabled,
|
|
288
305
|
props,
|
|
289
306
|
// `cursor` is style, so it travels in the style channel — put it first
|
package/src/desktopsettings.js
CHANGED
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
// XQuartz — the defaults below stand.
|
|
18
18
|
//
|
|
19
19
|
// So this reads the map `xsettings.js` already maintains, rather than
|
|
20
|
-
// standing up a source of its own.
|
|
20
|
+
// standing up a source of its own. The one exception is macOS, which has no
|
|
21
|
+
// XSETTINGS and answers exactly one of these — "reduce motion", through
|
|
22
|
+
// `NSWorkspace` — so the Cocoa app exposes that behind a two-method seam
|
|
23
|
+
// (`accessibilityDisplayOptions`, `watchAccessibilityDisplay`) and this
|
|
24
|
+
// module reads it the way it reads the map: synchronously, cached, live.
|
|
21
25
|
//
|
|
22
26
|
// ## Two of these are read synchronously, which is what shapes the module
|
|
23
27
|
//
|
|
@@ -85,6 +89,22 @@ function positive(map, key) {
|
|
|
85
89
|
* conversion, and forgetting to is a caret that blinks at half speed and
|
|
86
90
|
* looks broken rather than wrong.
|
|
87
91
|
*/
|
|
92
|
+
/**
|
|
93
|
+
* macOS's accessibility display options → the settings. Only `animations`
|
|
94
|
+
* comes from the system there: it is the one of these macOS publishes at all
|
|
95
|
+
* (System Settings › Accessibility › Display › Reduce motion, which the
|
|
96
|
+
* bridge reads from `NSWorkspace`), and the rest keep this renderer's
|
|
97
|
+
* defaults rather than being guessed from something that is not them. Pure,
|
|
98
|
+
* and exported for the test.
|
|
99
|
+
*/
|
|
100
|
+
export function fromMacOS(options) {
|
|
101
|
+
return Object.freeze({
|
|
102
|
+
...DEFAULTS,
|
|
103
|
+
animations: options?.reduceMotion !== true,
|
|
104
|
+
source: 'macos',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
88
108
|
export function fromXSettings(map) {
|
|
89
109
|
if (!map) return DEFAULTS;
|
|
90
110
|
const cycle = positive(map, 'Net/CursorBlinkTime');
|
|
@@ -126,7 +146,9 @@ export function desktopSettings(app) {
|
|
|
126
146
|
const session = sessions.get(app);
|
|
127
147
|
if (!session) return DEFAULTS;
|
|
128
148
|
if (!session.snapshot) {
|
|
129
|
-
session.snapshot =
|
|
149
|
+
session.snapshot = session.macos
|
|
150
|
+
? fromMacOS(app.accessibilityDisplayOptions())
|
|
151
|
+
: fromXSettings(xsettings(app));
|
|
130
152
|
}
|
|
131
153
|
return session.snapshot;
|
|
132
154
|
}
|
|
@@ -141,6 +163,16 @@ export function beginDesktopSettings(app) {
|
|
|
141
163
|
if (sessions.has(app)) return;
|
|
142
164
|
const session = { snapshot: null, listeners: new Set(), stop: null };
|
|
143
165
|
sessions.set(app, session);
|
|
166
|
+
// The Cocoa app: the answer is synchronous from the start, and the change
|
|
167
|
+
// arrives as a backend event rather than a property on a selection owner.
|
|
168
|
+
if (typeof app?.accessibilityDisplayOptions === 'function') {
|
|
169
|
+
session.macos = true;
|
|
170
|
+
session.stop = app.watchAccessibilityDisplay?.(() => {
|
|
171
|
+
session.snapshot = null;
|
|
172
|
+
notify(session);
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
144
176
|
beginXSettings(app).then(
|
|
145
177
|
() => {
|
|
146
178
|
if (!sessions.has(app)) return;
|