react-x11 2.6.1 → 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 +317 -1
- package/src/cocoa/bezels.js +51 -1
- 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/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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The tray on the cocoa backend — `NSStatusItem`, the menu-bar extra, through
|
|
2
|
+
// @windowkit/appkit (>= 0.5). The macOS half of the tray react-x11#353 asks
|
|
3
|
+
// for; `useTray` (src/trayhooks.js) is the API over it.
|
|
4
|
+
//
|
|
5
|
+
// An item shows an image (an SF Symbol by name, or PNG bytes drawn as a
|
|
6
|
+
// template so they follow the bar's light and dark) and/or a title, with a
|
|
7
|
+
// tooltip, and either owns a **menu** — the same `items` vocabulary the
|
|
8
|
+
// menu bar and the Dock menu take, through the one spec builder — or
|
|
9
|
+
// reports **clicks**, each carrying the item's screen rect, which is the
|
|
10
|
+
// anchor for a popup of the app's own.
|
|
11
|
+
//
|
|
12
|
+
// Two facts about the bridge shape the routing here:
|
|
13
|
+
//
|
|
14
|
+
// - A click names its item: the event's `statusItem` is the very handle
|
|
15
|
+
// `createStatusItem` returned, so the app keeps a Map keyed on it.
|
|
16
|
+
// - A menu activation does **not** — `menu-activate` says `menu: 'status'`
|
|
17
|
+
// and an id, and every item's menu allocates ids from its own snapshot. So
|
|
18
|
+
// each item's allocator starts in a stride of its own (`ID_STRIDE`), the
|
|
19
|
+
// ids of two trays' menus are disjoint by construction, and the app asks
|
|
20
|
+
// each item whether the id is one of its own.
|
|
21
|
+
import { IdAllocator, snapshot } from '../dbusmenu.js';
|
|
22
|
+
import { menuItemsSpec } from './globalmenu.js';
|
|
23
|
+
|
|
24
|
+
/** How far apart two items' id ranges start. A menu with a million rows is
|
|
25
|
+
* not a menu; the stride is the size of a number nobody reaches. */
|
|
26
|
+
const ID_STRIDE = 1_000_000;
|
|
27
|
+
let nextStride = 1;
|
|
28
|
+
|
|
29
|
+
/** react-x11's tray options as the bridge's item spec, keys it knows only. */
|
|
30
|
+
export function statusItemSpec(options = {}) {
|
|
31
|
+
const spec = {};
|
|
32
|
+
if (options.icon !== undefined) {
|
|
33
|
+
// an SF Symbol by name, or the encoded bytes of an image; null clears
|
|
34
|
+
spec.image = options.icon ?? null;
|
|
35
|
+
}
|
|
36
|
+
if (options.title !== undefined) spec.title = options.title ?? '';
|
|
37
|
+
if (options.tooltip !== undefined) spec.tooltip = options.tooltip ?? '';
|
|
38
|
+
if (options.visible !== undefined) spec.visible = options.visible !== false;
|
|
39
|
+
if (options.template !== undefined) spec.imageTemplate = options.template;
|
|
40
|
+
if (options.length !== undefined) spec.length = options.length;
|
|
41
|
+
if (Array.isArray(options.iconSize)) spec.imageSize = options.iconSize;
|
|
42
|
+
return spec;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class CocoaStatusItem {
|
|
46
|
+
constructor(app, options = {}) {
|
|
47
|
+
this.app = app;
|
|
48
|
+
this.alloc = new IdAllocator();
|
|
49
|
+
this.alloc.next = nextStride++ * ID_STRIDE + 1;
|
|
50
|
+
this.nodes = null;
|
|
51
|
+
this.removed = false;
|
|
52
|
+
this.onClick = options.onClick ?? null;
|
|
53
|
+
this.handle = app._native.createStatusItem(statusItemSpec(options));
|
|
54
|
+
this.setMenu(options.menu ?? null);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** In-place patch: only the keys present are touched. */
|
|
58
|
+
update(options = {}) {
|
|
59
|
+
if (this.removed) return;
|
|
60
|
+
this.onClick = options.onClick ?? null;
|
|
61
|
+
const spec = statusItemSpec(options);
|
|
62
|
+
if (Object.keys(spec).length) {
|
|
63
|
+
this.app._native.setStatusItem(this.handle, spec);
|
|
64
|
+
}
|
|
65
|
+
this.setMenu(options.menu ?? null);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `items`, or null to go back to reporting clicks. */
|
|
69
|
+
setMenu(items) {
|
|
70
|
+
if (this.removed) return;
|
|
71
|
+
if (!items) {
|
|
72
|
+
this.nodes = null;
|
|
73
|
+
this.app._native.setStatusItemMenu(this.handle, null);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.nodes = snapshot(items, this.alloc);
|
|
77
|
+
this.app._native.setStatusItemMenu(this.handle, menuItemsSpec(this.nodes));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Is this `menu-activate` id one of this item's menu's? */
|
|
81
|
+
owns(id) {
|
|
82
|
+
return Boolean(this.nodes?.has(id));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
activate(id) {
|
|
86
|
+
const item = this.nodes?.get(id)?.item;
|
|
87
|
+
item?.onSelect?.(item);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A `status-item-click` on this item, as the app's event. */
|
|
91
|
+
click(ev) {
|
|
92
|
+
this.onClick?.({
|
|
93
|
+
button: ev.kind,
|
|
94
|
+
x: ev.x,
|
|
95
|
+
y: ev.y,
|
|
96
|
+
width: ev.width,
|
|
97
|
+
height: ev.height,
|
|
98
|
+
clickCount: ev.clickCount ?? 1,
|
|
99
|
+
shift: Boolean(ev.shift),
|
|
100
|
+
control: Boolean(ev.control),
|
|
101
|
+
option: Boolean(ev.option),
|
|
102
|
+
command: Boolean(ev.command),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
remove() {
|
|
107
|
+
if (this.removed) return;
|
|
108
|
+
this.removed = true;
|
|
109
|
+
this.nodes = null;
|
|
110
|
+
this.app._native.removeStatusItem(this.handle);
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/cocoa/window.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// _screenOrigin. The divide-by-scale into Cocoa points happens against the
|
|
8
8
|
// native layer and nowhere above it.
|
|
9
9
|
import { CocoaContext2D } from './context2d.js';
|
|
10
|
+
import { CocoaDropTransport, dragSpec } from './dnd.js';
|
|
10
11
|
import { CocoaLayerPresenter } from './presenter.js';
|
|
11
12
|
|
|
12
13
|
let nextWindowId = 1;
|
|
@@ -93,16 +94,22 @@ export class CocoaWindow {
|
|
|
93
94
|
|
|
94
95
|
// The retained layer presenter (docs/macos.md Tier L), behind
|
|
95
96
|
// REACT_X11_COCOA_PRESENTER=layers while the surface path is the
|
|
96
|
-
// measured default. Its
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
// bitmap to blit.
|
|
97
|
+
// measured default. Its hooks exist only in this mode, so the feature
|
|
98
|
+
// detection in nodes.js keeps the surface path byte-identical; the
|
|
99
|
+
// scroll blit is shadowed off because a layer frame has no backing
|
|
100
|
+
// bitmap to blit. The last two are the animation seam: a transition or
|
|
101
|
+
// a loop the presenter takes runs in the render server and schedules no
|
|
102
|
+
// frames here (docs/architecture/animation.md §4).
|
|
100
103
|
if (app._presenterMode === 'layers') {
|
|
101
104
|
this._presenter = new CocoaLayerPresenter(this);
|
|
102
105
|
this.presentFrame = (windowNode) => this._presenter.frame(windowNode);
|
|
103
106
|
this.noteInvalidate = (damage, layoutChanged) =>
|
|
104
107
|
this._presenter.noteInvalidate(damage, layoutChanged);
|
|
105
108
|
this.scrollRegion = null;
|
|
109
|
+
this.animateNode = (node, prop, entry) =>
|
|
110
|
+
this._presenter.animate(node, prop, entry);
|
|
111
|
+
this.cancelNodeAnimation = (node, prop) =>
|
|
112
|
+
this._presenter.cancel(node, prop);
|
|
106
113
|
}
|
|
107
114
|
app._registerWindow(this);
|
|
108
115
|
}
|
|
@@ -211,6 +218,12 @@ export class CocoaWindow {
|
|
|
211
218
|
this.destroyed = true;
|
|
212
219
|
this.mapped = false;
|
|
213
220
|
this.app._unregisterWindow(this);
|
|
221
|
+
this._dropTransport = null;
|
|
222
|
+
// a bounce nobody can answer any more
|
|
223
|
+
if (this._attentionRequest != null) {
|
|
224
|
+
this.app.cancelAttention(this._attentionRequest);
|
|
225
|
+
this._attentionRequest = null;
|
|
226
|
+
}
|
|
214
227
|
this._native.destroyWindow2(this._h);
|
|
215
228
|
this._releaseBacking();
|
|
216
229
|
}
|
|
@@ -240,6 +253,74 @@ export class CocoaWindow {
|
|
|
240
253
|
|
|
241
254
|
setActions() {}
|
|
242
255
|
|
|
256
|
+
/**
|
|
257
|
+
* `_NET_WM_STATE` requests, as far as this backend has verbs for them:
|
|
258
|
+
* `demands_attention` is the Dock bounce (`requestUserAttention`), held
|
|
259
|
+
* until the state is removed or the window goes. Every other name resolves
|
|
260
|
+
* `false` — ntk's own contract for a state the server cannot honour —
|
|
261
|
+
* because the bridge has no zoom/miniaturize/fullscreen verbs yet
|
|
262
|
+
* (windowkit/appkit#15 scoped them out); nodes.js swallows the false.
|
|
263
|
+
*/
|
|
264
|
+
setWmState(names, action = 'add') {
|
|
265
|
+
const list = Array.isArray(names) ? names : [names];
|
|
266
|
+
let honoured = true;
|
|
267
|
+
for (const name of list) {
|
|
268
|
+
if (name !== 'demands_attention') {
|
|
269
|
+
honoured = false;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const held = this._attentionRequest != null;
|
|
273
|
+
const wants = action === 'add' || (action === 'toggle' && !held);
|
|
274
|
+
if (wants && !held && !this.destroyed) {
|
|
275
|
+
this._attentionRequest = this.app.requestAttention();
|
|
276
|
+
} else if (!wants && held) {
|
|
277
|
+
this.app.cancelAttention(this._attentionRequest);
|
|
278
|
+
this._attentionRequest = null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return Promise.resolve(honoured);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --- drag and drop (src/cocoa/dnd.js) ------------------------------------
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The drop side: nodes.js hands over the window's DropSession at realize
|
|
288
|
+
* (`_initDnd`), and from then on the app routes this window's `drag-*`
|
|
289
|
+
* events into it. Its presence on the window is what tells nodes.js the
|
|
290
|
+
* backend has drop machinery of its own.
|
|
291
|
+
*/
|
|
292
|
+
attachDropTransport(session, node) {
|
|
293
|
+
this._dropTransport = new CocoaDropTransport(this, session, node);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A `dropAccept` came or went under this window: re-register the types. */
|
|
297
|
+
dropTargetsChanged() {
|
|
298
|
+
this._dropTransport?.refreshTypes();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
registerDropTypes(types) {
|
|
302
|
+
if (this.destroyed) return;
|
|
303
|
+
this._native.registerDropTypes(this._h, types);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
setDropResponse(response) {
|
|
307
|
+
if (this.destroyed) return;
|
|
308
|
+
this._native.setDropResponse(this._h, response);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The source side: hand a DragSession's gesture to an NSDraggingSession
|
|
313
|
+
* (see src/cocoa/dnd.js for what the spec carries). Returns at once; the
|
|
314
|
+
* session reports back as `drag-session-*` events.
|
|
315
|
+
*/
|
|
316
|
+
beginDrag(session) {
|
|
317
|
+
if (this.destroyed) return null;
|
|
318
|
+
return this._native.beginDrag(
|
|
319
|
+
this._h,
|
|
320
|
+
dragSpec(session, this._native, this.scale),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
243
324
|
setTransientFor() {
|
|
244
325
|
// addChildWindow attachment comes with the layer presenter phase; a
|
|
245
326
|
// managed dialog already floats via its own window today.
|
package/src/components/Button.js
CHANGED
|
@@ -7,7 +7,11 @@ import { useAppOrNull } from '../appcontext.js';
|
|
|
7
7
|
import {
|
|
8
8
|
ABS_FILL,
|
|
9
9
|
Bezel,
|
|
10
|
+
NATIVE_RING,
|
|
11
|
+
TITLE_BASELINE,
|
|
10
12
|
bezelNatural,
|
|
13
|
+
bezelShadow,
|
|
14
|
+
nativeTitleStyle,
|
|
11
15
|
pressWash,
|
|
12
16
|
useNativeControls,
|
|
13
17
|
} from './native.js';
|
|
@@ -85,6 +89,7 @@ export function Button({
|
|
|
85
89
|
if (nativeControls && solid) {
|
|
86
90
|
const controlSize = small ? 'small' : 'regular';
|
|
87
91
|
const nat = bezelNatural(app, 'push', controlSize);
|
|
92
|
+
const shadow = bezelShadow(app, 'push', controlSize);
|
|
88
93
|
return h(
|
|
89
94
|
'box',
|
|
90
95
|
{
|
|
@@ -105,6 +110,20 @@ export function Button({
|
|
|
105
110
|
height: nat.height,
|
|
106
111
|
paddingLeft: small ? 10 : 14,
|
|
107
112
|
paddingRight: small ? 10 : 14,
|
|
113
|
+
// The natural box is the bezel's footprint, shadow included;
|
|
114
|
+
// the body is what the title is placed against — and placed,
|
|
115
|
+
// not centred (`TITLE_BASELINE`). A label centred by its
|
|
116
|
+
// capitals sat 1pt low beside a native button.
|
|
117
|
+
paddingTop: shadow.top,
|
|
118
|
+
paddingBottom: shadow.bottom + TITLE_BASELINE[controlSize],
|
|
119
|
+
// The keyboard ring is the renderer's, on this box — shaped by
|
|
120
|
+
// the bezel's corners and hugging it, as AppKit's is
|
|
121
|
+
// (`NATIVE_RING`), rather than the palette's offset rectangle.
|
|
122
|
+
borderRadius: small ? 5 : 6,
|
|
123
|
+
':focus-visible': {
|
|
124
|
+
outlineWidth: NATIVE_RING.width,
|
|
125
|
+
outlineOffset: NATIVE_RING.offset,
|
|
126
|
+
},
|
|
108
127
|
color: disabled
|
|
109
128
|
? theme.textMuted
|
|
110
129
|
: primary
|
|
@@ -122,7 +141,7 @@ export function Button({
|
|
|
122
141
|
isDefault: primary && !disabled,
|
|
123
142
|
style: ABS_FILL,
|
|
124
143
|
}),
|
|
125
|
-
labelContent(children ?? label),
|
|
144
|
+
labelContent(children ?? label, nativeTitleStyle(controlSize)),
|
|
126
145
|
// The press answer. Last child on purpose: `:active` marks the
|
|
127
146
|
// pressed node and its ancestors, and the topmost child is what the
|
|
128
147
|
// press lands on. No hover tint — AppKit buttons have none.
|
|
@@ -6,7 +6,14 @@ import React from 'react';
|
|
|
6
6
|
import { useAppOrNull } from '../appcontext.js';
|
|
7
7
|
import { changeEvent } from './change.js';
|
|
8
8
|
import { Icon } from './Icon.js';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
Bezel,
|
|
11
|
+
CONTROL_FONT_SIZE,
|
|
12
|
+
NO_ROW_RING,
|
|
13
|
+
bezelNatural,
|
|
14
|
+
nativeRingStyle,
|
|
15
|
+
useNativeControls,
|
|
16
|
+
} from './native.js';
|
|
10
17
|
import { focusRingStyle, labelContent, useControl, useTheme } from './theme.js';
|
|
11
18
|
|
|
12
19
|
const h = React.createElement;
|
|
@@ -44,6 +51,7 @@ export function Checkbox({
|
|
|
44
51
|
const {
|
|
45
52
|
hover,
|
|
46
53
|
focused,
|
|
54
|
+
focusVisible,
|
|
47
55
|
pressed,
|
|
48
56
|
props,
|
|
49
57
|
style: controlStyle,
|
|
@@ -69,6 +77,7 @@ export function Checkbox({
|
|
|
69
77
|
style: [
|
|
70
78
|
controlStyle,
|
|
71
79
|
{ flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
80
|
+
NO_ROW_RING,
|
|
72
81
|
style,
|
|
73
82
|
],
|
|
74
83
|
},
|
|
@@ -80,11 +89,17 @@ export function Checkbox({
|
|
|
80
89
|
style: {
|
|
81
90
|
width: nat.width,
|
|
82
91
|
height: nat.height,
|
|
83
|
-
|
|
92
|
+
// the box's own corners, so the ring rounds as AppKit's does
|
|
93
|
+
...nativeRingStyle(theme, focusVisible, 4),
|
|
84
94
|
},
|
|
85
95
|
}),
|
|
96
|
+
// At the control font size, as the cell's own title is: the bezel is
|
|
97
|
+
// designed beside 13pt, and the palette's 14px read a size too large
|
|
98
|
+
// next to it. Centred on the box, which is where AppKit centres it
|
|
99
|
+
// (measured: within a quarter point).
|
|
86
100
|
labelContent(children ?? label, {
|
|
87
101
|
color: disabled ? theme.textMuted : theme.text,
|
|
102
|
+
fontSize: CONTROL_FONT_SIZE.regular,
|
|
88
103
|
}),
|
|
89
104
|
);
|
|
90
105
|
}
|
package/src/components/Menu.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
useTheme,
|
|
12
12
|
} from './theme.js';
|
|
13
13
|
import { Icon, iconSize } from './Icon.js';
|
|
14
|
+
import { NATIVE_MENU, useNativeControls } from './native.js';
|
|
14
15
|
import {
|
|
15
16
|
DEFAULT_LABEL_SIZE,
|
|
16
17
|
anchorArea,
|
|
@@ -81,6 +82,53 @@ const MENU_PAD = 4;
|
|
|
81
82
|
// borders on its *controls* does not mean a 2px outline around every menu.
|
|
82
83
|
const MENU_BORDER = 1;
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The geometry of a popup — rows, padding, separator, text — as one
|
|
87
|
+
* object, so the sizing arithmetic and the layout read one source.
|
|
88
|
+
*
|
|
89
|
+
* Two sources: the drawn menu's own numbers, derived from the text size as
|
|
90
|
+
* they always were, or NSMenu's where the backend renders native controls
|
|
91
|
+
* (`NATIVE_MENU`): 22pt rows, 5pt of padding, an 11pt separator, the 13pt
|
|
92
|
+
* menu font at regular weight, and no pressed step. A context menu on the
|
|
93
|
+
* Cocoa backend is then the menu the system's own controls open, beside
|
|
94
|
+
* which the drawn one — 30pt rows of 14px medium — read as a different
|
|
95
|
+
* toolkit's. The bar keeps its drawn metrics: a menu bar is not an NSMenu.
|
|
96
|
+
*/
|
|
97
|
+
function menuMetrics(theme, native, fontSize) {
|
|
98
|
+
if (native) {
|
|
99
|
+
return {
|
|
100
|
+
fontSize: NATIVE_MENU.fontSize,
|
|
101
|
+
weight: NATIVE_MENU.weight,
|
|
102
|
+
row: NATIVE_MENU.row,
|
|
103
|
+
pad: NATIVE_MENU.pad,
|
|
104
|
+
itemPad: NATIVE_MENU.padLeft,
|
|
105
|
+
separator: NATIVE_MENU.separator,
|
|
106
|
+
radius: NATIVE_MENU.radius,
|
|
107
|
+
press: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
fontSize,
|
|
112
|
+
weight: MENU_TEXT_WEIGHT,
|
|
113
|
+
row: menuRowHeight(fontSize),
|
|
114
|
+
pad: MENU_PAD,
|
|
115
|
+
itemPad: MENU_ITEM_PAD,
|
|
116
|
+
separator: MENU_SEPARATOR_HEIGHT,
|
|
117
|
+
radius: rowRadius(theme, MENU_BORDER, MENU_PAD),
|
|
118
|
+
// a drawn row steps to `accentActive` while pressed
|
|
119
|
+
press: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function useMenuMetrics(fontSize) {
|
|
124
|
+
const theme = useTheme();
|
|
125
|
+
const native = useNativeControls();
|
|
126
|
+
return useMemo(
|
|
127
|
+
() => menuMetrics(theme, native, fontSize),
|
|
128
|
+
[theme, native, fontSize],
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
84
132
|
// A bar item wears the same pill as a row in the menu it opens, so it takes
|
|
85
133
|
// the row's padding rather than numbers of its own: a title packed tighter
|
|
86
134
|
// than its own first row is the tell that the two were measured separately.
|
|
@@ -173,13 +221,12 @@ const MENU_SHORTCUT_GAP = 24;
|
|
|
173
221
|
const MENU_PAGE_ROWS = 10;
|
|
174
222
|
|
|
175
223
|
/** Total popup height for a menu's items (separators are shorter). */
|
|
176
|
-
function menuListHeight(items,
|
|
177
|
-
const row = menuRowHeight(fontSize);
|
|
224
|
+
function menuListHeight(items, metrics) {
|
|
178
225
|
const body = visibleItems(items).reduce(
|
|
179
|
-
(sum, item) => sum + (isSeparator(item) ?
|
|
226
|
+
(sum, item) => sum + (isSeparator(item) ? metrics.separator : metrics.row),
|
|
180
227
|
0,
|
|
181
228
|
);
|
|
182
|
-
return body + (
|
|
229
|
+
return body + (metrics.pad + MENU_BORDER) * 2;
|
|
183
230
|
}
|
|
184
231
|
|
|
185
232
|
/**
|
|
@@ -214,19 +261,19 @@ function menuGutter(items) {
|
|
|
214
261
|
}
|
|
215
262
|
|
|
216
263
|
/** Widest label + shortcut, measured, so the popup can be sized up front. */
|
|
217
|
-
function menuListWidth(node, items,
|
|
264
|
+
function menuListWidth(node, items, metrics) {
|
|
218
265
|
let widest = 0;
|
|
219
266
|
for (const item of visibleItems(items)) {
|
|
220
267
|
if (isSeparator(item)) continue;
|
|
221
268
|
const label = measureLabel(node, item.label ?? '', {
|
|
222
|
-
size: fontSize,
|
|
223
|
-
weight:
|
|
269
|
+
size: metrics.fontSize,
|
|
270
|
+
weight: metrics.weight,
|
|
224
271
|
}).width;
|
|
225
272
|
const accelerator = formatShortcut(item.shortcut);
|
|
226
273
|
const shortcut = accelerator
|
|
227
274
|
? measureLabel(node, accelerator, {
|
|
228
|
-
size: fontSize,
|
|
229
|
-
weight:
|
|
275
|
+
size: metrics.fontSize,
|
|
276
|
+
weight: metrics.weight,
|
|
230
277
|
}).width + MENU_SHORTCUT_GAP
|
|
231
278
|
: 0;
|
|
232
279
|
widest = Math.max(widest, label + shortcut);
|
|
@@ -235,8 +282,8 @@ function menuListWidth(node, items, fontSize) {
|
|
|
235
282
|
MENU_MIN_WIDTH,
|
|
236
283
|
Math.ceil(widest) +
|
|
237
284
|
menuGutter(items) +
|
|
238
|
-
(
|
|
239
|
-
|
|
285
|
+
(metrics.pad + MENU_BORDER) * 2 +
|
|
286
|
+
metrics.itemPad * 2 +
|
|
240
287
|
2,
|
|
241
288
|
);
|
|
242
289
|
}
|
|
@@ -393,7 +440,7 @@ function MenuRow({
|
|
|
393
440
|
onHover,
|
|
394
441
|
onMove,
|
|
395
442
|
onSelect,
|
|
396
|
-
|
|
443
|
+
metrics,
|
|
397
444
|
// The width of the level's check column — the same number the popup was
|
|
398
445
|
// sized with, passed down rather than asked of the item, since a row with
|
|
399
446
|
// no mark of its own still keeps the column its neighbours need.
|
|
@@ -411,7 +458,10 @@ function MenuRow({
|
|
|
411
458
|
{
|
|
412
459
|
theme,
|
|
413
460
|
role: 'separator',
|
|
414
|
-
|
|
461
|
+
// The level hears the pointer arrive here too: it cannot land, so
|
|
462
|
+
// whatever was lit goes out, as it does in a native menu.
|
|
463
|
+
onMouseEnter: onHover,
|
|
464
|
+
style: { height: metrics.separator, justifyContent: 'center' },
|
|
415
465
|
},
|
|
416
466
|
h('box', { style: { height: 1, backgroundColor: theme.border } }),
|
|
417
467
|
);
|
|
@@ -444,20 +494,25 @@ function MenuRow({
|
|
|
444
494
|
'aria-expanded': submenu ? state === 'path' : undefined,
|
|
445
495
|
disabled: dim || undefined,
|
|
446
496
|
ref: nodeRef,
|
|
447
|
-
|
|
448
|
-
|
|
497
|
+
// A disabled row reports the pointer as any row does; the level
|
|
498
|
+
// answers by lighting nothing (`hover`). Silence here was the bug: the
|
|
499
|
+
// pointer crossed onto a disabled row and the row it had left stayed
|
|
500
|
+
// lit until it reached the next enabled one — a native menu goes dark
|
|
501
|
+
// the moment the pointer is over anything it cannot choose.
|
|
502
|
+
onMouseEnter: onHover,
|
|
503
|
+
onMouseMove: onMove,
|
|
449
504
|
onClick: dim ? undefined : () => onSelect(item),
|
|
450
505
|
style: {
|
|
451
|
-
height:
|
|
506
|
+
height: metrics.row,
|
|
452
507
|
flexDirection: 'row',
|
|
453
508
|
alignItems: 'center',
|
|
454
|
-
paddingLeft:
|
|
455
|
-
paddingRight:
|
|
509
|
+
paddingLeft: metrics.itemPad,
|
|
510
|
+
paddingRight: metrics.itemPad,
|
|
456
511
|
// A pill inside the sheet: the row is inset from the popup edge by
|
|
457
512
|
// the list's padding, and rounded so that its corner and the sheet's
|
|
458
513
|
// share a centre — the two curves are then one shape rather than two
|
|
459
514
|
// that nearly agree.
|
|
460
|
-
borderRadius:
|
|
515
|
+
borderRadius: metrics.radius,
|
|
461
516
|
// Nothing at rest: the sheet under it is already that colour, and
|
|
462
517
|
// now that the row is rounded, repainting it per row would be a
|
|
463
518
|
// coverage mask drawn to change nothing — with four corners it
|
|
@@ -474,8 +529,11 @@ function MenuRow({
|
|
|
474
529
|
// the item is already highlighted by the time it can be pressed, so
|
|
475
530
|
// the press is a further step down rather than a first one — without
|
|
476
531
|
// it the command runs on the release out of a picture that never
|
|
477
|
-
// changed
|
|
478
|
-
|
|
532
|
+
// changed. Not under native controls: NSMenu has no pressed look,
|
|
533
|
+
// the highlight simply stays, and the palette's pressed step there
|
|
534
|
+
// is AppKit's deep-pressed cut of the accent — on a dark orange
|
|
535
|
+
// palette, a yellow no native menu ever shows.
|
|
536
|
+
...(dim || !metrics.press
|
|
479
537
|
? null
|
|
480
538
|
: { ':active': { backgroundColor: theme.accentActive } }),
|
|
481
539
|
},
|
|
@@ -490,11 +548,16 @@ function MenuRow({
|
|
|
490
548
|
// stand clear, and the row's own padding already spaces the mark.
|
|
491
549
|
style: { width: gutter, alignItems: 'flex-start' },
|
|
492
550
|
},
|
|
493
|
-
gutterMark(item, { color: rowInk, fontSize }),
|
|
551
|
+
gutterMark(item, { color: rowInk, fontSize: metrics.fontSize }),
|
|
494
552
|
),
|
|
495
553
|
h(
|
|
496
554
|
'text',
|
|
497
|
-
{
|
|
555
|
+
{
|
|
556
|
+
style: [
|
|
557
|
+
capTrim,
|
|
558
|
+
{ fontSize: metrics.fontSize, fontWeight: metrics.weight },
|
|
559
|
+
],
|
|
560
|
+
},
|
|
498
561
|
item.label,
|
|
499
562
|
),
|
|
500
563
|
h('box', { style: { flexGrow: 1 } }),
|
|
@@ -508,7 +571,7 @@ function MenuRow({
|
|
|
508
571
|
{
|
|
509
572
|
style: [
|
|
510
573
|
capTrim,
|
|
511
|
-
{ fontSize, fontWeight:
|
|
574
|
+
{ fontSize: metrics.fontSize, fontWeight: metrics.weight },
|
|
512
575
|
!active && { color: theme.textMuted },
|
|
513
576
|
],
|
|
514
577
|
},
|
|
@@ -522,7 +585,7 @@ function MenuRow({
|
|
|
522
585
|
// The capitals of the row, not the gutter's 16px column: a chevron
|
|
523
586
|
// stands as tall as its box, so `MENU_ICON_SIZE` would put an arrow
|
|
524
587
|
// beside the label taller than the label.
|
|
525
|
-
size: capBand(fontSize),
|
|
588
|
+
size: capBand(metrics.fontSize),
|
|
526
589
|
// no `color`: the arrow is the row saying it has more behind it, as
|
|
527
590
|
// much a part of the entry as its label, and a muted one reads as a
|
|
528
591
|
// row that is half disabled rather than one with a submenu
|
|
@@ -563,6 +626,7 @@ function MenuLevel({
|
|
|
563
626
|
fontSize,
|
|
564
627
|
}) {
|
|
565
628
|
const theme = useTheme();
|
|
629
|
+
const metrics = useMenuMetrics(fontSize);
|
|
566
630
|
const items = levelItems(rootItems, path, depth);
|
|
567
631
|
// one answer for the level, so every row indents by the same amount the
|
|
568
632
|
// popup was measured with
|
|
@@ -606,13 +670,13 @@ function MenuLevel({
|
|
|
606
670
|
// continuation six pixels lower than the row itself. Shifting by
|
|
607
671
|
// the inset puts the first item exactly beside the item it came
|
|
608
672
|
// out of, which is where the eye is already looking.
|
|
609
|
-
alignOffset: -(MENU_BORDER +
|
|
673
|
+
alignOffset: -(MENU_BORDER + metrics.pad),
|
|
610
674
|
offset: SUBMENU_GAP,
|
|
611
|
-
width: menuListWidth(node, childItems,
|
|
612
|
-
height: menuListHeight(childItems,
|
|
675
|
+
width: menuListWidth(node, childItems, metrics),
|
|
676
|
+
height: menuListHeight(childItems, metrics),
|
|
613
677
|
}),
|
|
614
678
|
);
|
|
615
|
-
}, [childOpen, active, depth,
|
|
679
|
+
}, [childOpen, active, depth, metrics, rect.x, rect.y]);
|
|
616
680
|
|
|
617
681
|
// "safe polygon" hover: while a submenu is open, the triangle between the
|
|
618
682
|
// pointer and the submenu's near edge counts as still hovering the parent
|
|
@@ -639,7 +703,11 @@ function MenuLevel({
|
|
|
639
703
|
setPath(hasSubmenu(items[index]) ? [...base, -1] : base);
|
|
640
704
|
};
|
|
641
705
|
|
|
642
|
-
const hover = (
|
|
706
|
+
const hover = (row, ev) => {
|
|
707
|
+
// Over a row the selection cannot land on — disabled, a separator —
|
|
708
|
+
// nothing is lit: `-1` is the same "no row" the keyboard's Home starts
|
|
709
|
+
// from, and the polygon below still holds an open submenu across it.
|
|
710
|
+
const index = isSelectable(items[row]) ? row : -1;
|
|
643
711
|
const point = screenPoint(ev);
|
|
644
712
|
if (
|
|
645
713
|
index !== active &&
|
|
@@ -712,7 +780,7 @@ function MenuLevel({
|
|
|
712
780
|
style: {
|
|
713
781
|
flexGrow: 1,
|
|
714
782
|
flexShrink: 1,
|
|
715
|
-
padding:
|
|
783
|
+
padding: metrics.pad,
|
|
716
784
|
borderWidth: MENU_BORDER,
|
|
717
785
|
borderColor: theme.border,
|
|
718
786
|
backgroundColor: theme.surface,
|
|
@@ -724,7 +792,7 @@ function MenuLevel({
|
|
|
724
792
|
key: isSeparator(item) ? `sep-${index}` : (item.key ?? item.label),
|
|
725
793
|
item,
|
|
726
794
|
state: rowState(index, active, handedOn),
|
|
727
|
-
|
|
795
|
+
metrics,
|
|
728
796
|
gutter,
|
|
729
797
|
nodeRef: index === active ? activeRowRef : undefined,
|
|
730
798
|
onHover: (ev) => hover(index, ev),
|
|
@@ -887,6 +955,7 @@ export function ContextMenu({
|
|
|
887
955
|
const [path, setPath] = useState([]);
|
|
888
956
|
const typeAhead = useTypeAhead();
|
|
889
957
|
const rtl = useDirection() === 'rtl';
|
|
958
|
+
const metrics = useMenuMetrics(fontSize);
|
|
890
959
|
|
|
891
960
|
const close = () => {
|
|
892
961
|
setRect(null);
|
|
@@ -908,8 +977,8 @@ export function ContextMenu({
|
|
|
908
977
|
const openAt = (ev) => {
|
|
909
978
|
const node = ref.current;
|
|
910
979
|
if (!node || !items.length) return;
|
|
911
|
-
const width = menuListWidth(node, items,
|
|
912
|
-
const height = menuListHeight(items,
|
|
980
|
+
const width = menuListWidth(node, items, metrics);
|
|
981
|
+
const height = menuListHeight(items, metrics);
|
|
913
982
|
const area = anchorArea(node);
|
|
914
983
|
// anchored at the pointer rather than at a widget: clamp by hand, since
|
|
915
984
|
// there is no anchor rect to flip around. Root coordinates are device;
|
|
@@ -1006,6 +1075,7 @@ export function MenuBar({
|
|
|
1006
1075
|
}) {
|
|
1007
1076
|
const theme = useTheme();
|
|
1008
1077
|
const rtl = useDirection() === 'rtl';
|
|
1078
|
+
const metrics = useMenuMetrics(fontSize);
|
|
1009
1079
|
const [openIndex, setOpenIndex] = useState(-1);
|
|
1010
1080
|
// The pointer's title, tracked here rather than left to a `:hover` block,
|
|
1011
1081
|
// because the thing that lights up is no longer the node the pointer is
|
|
@@ -1111,8 +1181,8 @@ export function MenuBar({
|
|
|
1111
1181
|
const node = refs.current[index];
|
|
1112
1182
|
const menu = entries[index];
|
|
1113
1183
|
if (!node || !hasSubmenu(menu)) return;
|
|
1114
|
-
const width = menuListWidth(node, menu.items,
|
|
1115
|
-
const height = menuListHeight(menu.items,
|
|
1184
|
+
const width = menuListWidth(node, menu.items, metrics);
|
|
1185
|
+
const height = menuListHeight(menu.items, metrics);
|
|
1116
1186
|
const next = anchorRect(node, { placement: 'bottom', width, height });
|
|
1117
1187
|
if (!next) return;
|
|
1118
1188
|
openRef.current = index;
|
|
@@ -1159,8 +1229,8 @@ export function MenuBar({
|
|
|
1159
1229
|
if (!node || !hasSubmenu(menu)) return null;
|
|
1160
1230
|
return {
|
|
1161
1231
|
placement: 'bottom',
|
|
1162
|
-
width: menuListWidth(node, menu.items,
|
|
1163
|
-
height: menuListHeight(menu.items,
|
|
1232
|
+
width: menuListWidth(node, menu.items, metrics),
|
|
1233
|
+
height: menuListHeight(menu.items, metrics),
|
|
1164
1234
|
};
|
|
1165
1235
|
},
|
|
1166
1236
|
setRect,
|