react-x11 2.15.2 → 2.16.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 +37 -0
- package/package.json +4 -3
- package/src/Reconciler.js +85 -22
- package/src/anchor.js +60 -18
- package/src/application.js +25 -1
- package/src/capabilities.js +349 -0
- package/src/cocoa/app.js +28 -9
- package/src/cocoa/context2d.js +139 -6
- package/src/cocoa/fonts.js +78 -0
- package/src/cocoa/presenter.js +17 -0
- package/src/cocoa/promotion.js +20 -0
- package/src/cocoa/relaunch.js +8 -3
- package/src/cocoa/symbols.js +64 -0
- package/src/cocoa/threaded.js +24 -4
- package/src/cocoa/window.js +362 -139
- package/src/components/ProgressBar.js +1 -1
- package/src/components/Slider.js +72 -39
- package/src/components/anchor.js +7 -2
- package/src/components/index.js +1 -0
- package/src/components/theme.js +32 -28
- package/src/dbusmenuexport.js +243 -0
- package/src/desktopcapabilityhooks.js +160 -0
- package/src/filedialoghooks.js +3 -5
- package/src/frame/childmain.js +8 -20
- package/src/frame/env.js +2 -10
- package/src/globalmenu.js +3 -205
- package/src/icontheme.js +240 -0
- package/src/imagesource.js +98 -3
- package/src/index.d.ts +1 -0
- package/src/index.js +11 -2
- package/src/launcher.js +235 -32
- package/src/launcherhooks.js +47 -28
- package/src/node.d.ts +7 -0
- package/src/nodes/animation.js +17 -47
- package/src/nodes/cascade.js +17 -2
- package/src/nodes/image.js +65 -2
- package/src/nodes/kinds.js +12 -0
- package/src/nodes/layout.js +5 -1
- package/src/nodes/node.js +17 -3
- package/src/nodes/paint.js +117 -0
- package/src/nodes/scope.js +259 -0
- package/src/nodes/scrollable.js +53 -6
- package/src/nodes/text.js +2 -0
- package/src/nodes/textarea.js +1 -1
- package/src/nodes/textinput.js +1 -1
- package/src/nodes/window/anchoring.js +45 -18
- package/src/nodes/window/flush.js +6 -5
- package/src/nodes/window/popup.js +10 -0
- package/src/nodes/window/size.js +40 -2
- package/src/nodes/window/window.js +41 -14
- package/src/registry.js +2 -1
- package/src/settings.js +332 -0
- package/src/statusnotifier.js +752 -0
- package/src/styles.js +212 -8
- package/src/symbols.js +200 -0
- package/src/testing/mock-app.js +10 -0
- package/src/trayhooks.js +193 -29
- package/src/types/capabilities.d.ts +139 -0
- package/src/types/components.d.ts +33 -0
- package/src/types/elements.d.ts +57 -6
- package/src/types/launcher.d.ts +50 -4
- package/src/types/style.d.ts +57 -0
- package/src/types/system.d.ts +104 -0
- package/src/types/tray.d.ts +64 -6
|
@@ -25,7 +25,7 @@ const CROSSING_MS = 1100;
|
|
|
25
25
|
*
|
|
26
26
|
* The slide is a **loop** in the style (`animation`,
|
|
27
27
|
* [styling.md](../../docs/styling.md#loops)), not a timer here: it runs on
|
|
28
|
-
* the window's own frame clock, claims the
|
|
28
|
+
* the window's own frame clock, claims the block as its damage every frame
|
|
29
29
|
* instead of invalidating the window, and stops itself when the window is
|
|
30
30
|
* unmapped, minimized or buried. A `setInterval` calling `setState` would
|
|
31
31
|
* do none of those three, and would re-render this component sixty times a
|
package/src/components/Slider.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import React, { useRef, useState } from 'react';
|
|
6
6
|
import { useAppOrNull } from '../appcontext.js';
|
|
7
|
+
import { flattenStyle } from '../styles.js';
|
|
7
8
|
import { Bezel, bezelNatural, useNativeControls } from './native.js';
|
|
8
9
|
import { useTheme } from './theme.js';
|
|
9
10
|
import { changeEvent } from './change.js';
|
|
@@ -21,7 +22,10 @@ import {
|
|
|
21
22
|
const h = React.createElement;
|
|
22
23
|
|
|
23
24
|
const SLIDER_THUMB = 16;
|
|
24
|
-
|
|
25
|
+
|
|
26
|
+
/** A length a slot's style gave, when it is a number of pixels. */
|
|
27
|
+
const lengthIn = (style, key) =>
|
|
28
|
+
typeof style[key] === 'number' ? style[key] : undefined;
|
|
25
29
|
|
|
26
30
|
/**
|
|
27
31
|
* <Slider value min max step onChange disabled …boxProps> — draggable
|
|
@@ -34,6 +38,15 @@ const SLIDER_SLOP = (24 - SLIDER_THUMB) / 2;
|
|
|
34
38
|
*
|
|
35
39
|
* Keyboard: arrows step, Home/End jump to the ends, PageUp/PageDown move
|
|
36
40
|
* by ten steps.
|
|
41
|
+
*
|
|
42
|
+
* `thumbStyle`, `trackStyle` and `fillStyle` are style slots over the three
|
|
43
|
+
* drawn parts (#593), each merged over the part's own default, so a designed
|
|
44
|
+
* slider — a flat accent thumb with a shadow and no ring on a hairline track —
|
|
45
|
+
* is the widget restyled rather than rebuilt from boxes. The thumb's `width`
|
|
46
|
+
* and `height` are read back: the pointer's travel is the track less one
|
|
47
|
+
* thumb, and the control is as tall as the taller of thumb and track. Any
|
|
48
|
+
* slot also chooses the drawn slider over the platform's own, whose pixels
|
|
49
|
+
* no style reaches.
|
|
37
50
|
*/
|
|
38
51
|
export function Slider({
|
|
39
52
|
value = 0,
|
|
@@ -46,11 +59,22 @@ export function Slider({
|
|
|
46
59
|
height = 4,
|
|
47
60
|
native,
|
|
48
61
|
style,
|
|
62
|
+
thumbStyle,
|
|
63
|
+
trackStyle,
|
|
64
|
+
fillStyle,
|
|
49
65
|
...boxProps
|
|
50
66
|
}) {
|
|
51
67
|
const theme = useTheme();
|
|
52
68
|
const app = useAppOrNull();
|
|
53
|
-
const
|
|
69
|
+
const styled = Boolean(thumbStyle || trackStyle || fillStyle);
|
|
70
|
+
const nativeControls = useNativeControls(styled ? false : native);
|
|
71
|
+
// the sizes the drawing and the pointer math have to agree on
|
|
72
|
+
const thumb = flattenStyle(thumbStyle);
|
|
73
|
+
const thumbWidth = lengthIn(thumb, 'width') ?? SLIDER_THUMB;
|
|
74
|
+
const thumbHeight = lengthIn(thumb, 'height') ?? thumbWidth;
|
|
75
|
+
const trackHeight = lengthIn(flattenStyle(trackStyle), 'height') ?? height;
|
|
76
|
+
const controlHeight = Math.max(thumbHeight, trackHeight);
|
|
77
|
+
const slop = Math.max(0, (24 - controlHeight) / 2);
|
|
54
78
|
const [focused, setFocused] = useState(false);
|
|
55
79
|
const [dragging, setDragging] = useState(false);
|
|
56
80
|
const trackRef = useRef(null);
|
|
@@ -88,11 +112,11 @@ export function Slider({
|
|
|
88
112
|
if (!rect?.width) return value;
|
|
89
113
|
// the thumb is centred on the value, so the usable travel is the track
|
|
90
114
|
// minus one thumb width — otherwise min/max are unreachable at the ends
|
|
91
|
-
const travel = Math.max(1, rect.width -
|
|
115
|
+
const travel = Math.max(1, rect.width - thumbWidth);
|
|
92
116
|
const x =
|
|
93
117
|
node.direction === 'rtl'
|
|
94
|
-
? rect.x + rect.width - ev.x -
|
|
95
|
-
: ev.x - rect.x -
|
|
118
|
+
? rect.x + rect.width - ev.x - thumbWidth / 2
|
|
119
|
+
: ev.x - rect.x - thumbWidth / 2;
|
|
96
120
|
return quantize(min + (Math.min(travel, Math.max(0, x)) / travel) * span);
|
|
97
121
|
};
|
|
98
122
|
|
|
@@ -226,7 +250,7 @@ export function Slider({
|
|
|
226
250
|
style: [
|
|
227
251
|
disabled || { cursor: 'pointer' },
|
|
228
252
|
{
|
|
229
|
-
height:
|
|
253
|
+
height: controlHeight,
|
|
230
254
|
minWidth: 0,
|
|
231
255
|
justifyContent: 'center',
|
|
232
256
|
// the control is as tall as its thumb and the track inside it sets
|
|
@@ -234,7 +258,7 @@ export function Slider({
|
|
|
234
258
|
// WCAG 2.2 SC 2.5.8's 24 on the axis that is short, without moving
|
|
235
259
|
// a pixel of the drawing — a taller slider would misalign every row
|
|
236
260
|
// it sits in.
|
|
237
|
-
hitSlop: { top:
|
|
261
|
+
hitSlop: { top: slop, bottom: slop },
|
|
238
262
|
},
|
|
239
263
|
style,
|
|
240
264
|
],
|
|
@@ -243,14 +267,16 @@ export function Slider({
|
|
|
243
267
|
h(
|
|
244
268
|
'box',
|
|
245
269
|
{
|
|
246
|
-
style:
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
270
|
+
style: [
|
|
271
|
+
{
|
|
272
|
+
height: height,
|
|
273
|
+
borderRadius: height / 2,
|
|
274
|
+
backgroundColor: theme.track,
|
|
275
|
+
},
|
|
276
|
+
trackStyle,
|
|
277
|
+
// the parts of the track the fill and the pointer depend on
|
|
278
|
+
{ flexDirection: 'row', alignItems: 'center', pointerEvents: 'none' },
|
|
279
|
+
],
|
|
254
280
|
},
|
|
255
281
|
// flex ratios, not a percentage width: a percentage child resolves
|
|
256
282
|
// against the space available while the track is still being
|
|
@@ -258,14 +284,15 @@ export function Slider({
|
|
|
258
284
|
// at value = max the control grew, which moved the handle, which
|
|
259
285
|
// changed the value, and a drag turned into an oscillation
|
|
260
286
|
h('box', {
|
|
261
|
-
style:
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
287
|
+
style: [
|
|
288
|
+
{
|
|
289
|
+
height: trackHeight,
|
|
290
|
+
borderRadius: trackHeight / 2,
|
|
291
|
+
backgroundColor: disabled ? theme.textMuted : theme.accent,
|
|
292
|
+
},
|
|
293
|
+
fillStyle,
|
|
294
|
+
{ flexGrow: fraction, flexShrink: 0, flexBasis: 0 },
|
|
295
|
+
],
|
|
269
296
|
}),
|
|
270
297
|
h('box', {
|
|
271
298
|
style: { flexGrow: 1 - fraction, flexShrink: 0, flexBasis: 0 },
|
|
@@ -276,22 +303,28 @@ export function Slider({
|
|
|
276
303
|
// mirror on their own — a `row` runs the other way under `direction:
|
|
277
304
|
// 'rtl'` — and these are what keep the thumb on top of the join.
|
|
278
305
|
h('box', {
|
|
279
|
-
style:
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
306
|
+
style: [
|
|
307
|
+
{
|
|
308
|
+
width: thumbWidth,
|
|
309
|
+
height: thumbHeight,
|
|
310
|
+
borderRadius: Math.min(thumbWidth, thumbHeight) / 2,
|
|
311
|
+
borderWidth: 1,
|
|
312
|
+
borderColor: disabled
|
|
313
|
+
? theme.border
|
|
314
|
+
: focused || dragging
|
|
315
|
+
? theme.accentHover
|
|
316
|
+
: theme.border,
|
|
317
|
+
backgroundColor: disabled ? theme.surfaceHover : theme.surface,
|
|
318
|
+
},
|
|
319
|
+
thumbStyle,
|
|
320
|
+
// where it is: the value's place on the travel the math uses
|
|
321
|
+
{
|
|
322
|
+
position: 'absolute',
|
|
323
|
+
start: `${fraction * 100}%`,
|
|
324
|
+
marginStart: -thumbWidth * fraction,
|
|
325
|
+
pointerEvents: 'none',
|
|
326
|
+
},
|
|
327
|
+
],
|
|
295
328
|
}),
|
|
296
329
|
);
|
|
297
330
|
}
|
package/src/components/anchor.js
CHANGED
|
@@ -11,6 +11,7 @@ export {
|
|
|
11
11
|
anchorArea,
|
|
12
12
|
anchorOffscreen,
|
|
13
13
|
anchorRect,
|
|
14
|
+
anchorScreenRect,
|
|
14
15
|
centerRect,
|
|
15
16
|
screenRect,
|
|
16
17
|
subRect,
|
|
@@ -177,8 +178,10 @@ export function measureLabel(node, text, style) {
|
|
|
177
178
|
// `{ opsz: 17 }` on its window — the text cut of a variable face, wider
|
|
178
179
|
// than the display cut most files default to — draws every row in it and
|
|
179
180
|
// used to measure none, and the popup came out narrow enough to wrap its
|
|
180
|
-
// own labels.
|
|
181
|
-
//
|
|
181
|
+
// own labels. `letterSpacing` and the OpenType features move advances the
|
|
182
|
+
// same way (a device length and a tag bag, as they cascade). An explicit
|
|
183
|
+
// `style` still wins, since a caller that names a face is measuring
|
|
184
|
+
// something it is about to draw in that face.
|
|
182
185
|
const inherited = node?.inheritedTextStyle;
|
|
183
186
|
const layout = fonts.layout(String(text), {
|
|
184
187
|
family: style?.family ?? inherited?.family ?? 'sans-serif',
|
|
@@ -186,6 +189,8 @@ export function measureLabel(node, text, style) {
|
|
|
186
189
|
weight: style?.weight ?? inherited?.weight ?? 'normal',
|
|
187
190
|
style: style?.style ?? inherited?.style,
|
|
188
191
|
variations: style?.variations ?? inherited?.variations,
|
|
192
|
+
letterSpacing: inherited?.letterSpacing,
|
|
193
|
+
features: inherited?.features,
|
|
189
194
|
});
|
|
190
195
|
return { width: layout.width / s, height: layout.height / s };
|
|
191
196
|
}
|
package/src/components/index.js
CHANGED
package/src/components/theme.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
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 { THEME_SCOPE } from '../nodes/kinds.js';
|
|
13
14
|
import {
|
|
14
15
|
DarkTheme,
|
|
15
16
|
DefaultTheme,
|
|
@@ -30,8 +31,9 @@ export { DarkTheme, DefaultTheme, resolveTheme };
|
|
|
30
31
|
// has to merge again.
|
|
31
32
|
const ThemeContext = React.createContext(null);
|
|
32
33
|
|
|
33
|
-
//
|
|
34
|
-
// wants; `style` is there for the ones that wrap a single
|
|
34
|
+
// Inside a window the provider's box fills its parent, which is what an
|
|
35
|
+
// app-level provider wants; `style` is there for the ones that wrap a single
|
|
36
|
+
// control. Above the windows there is nothing to fill, and neither applies.
|
|
35
37
|
const FILL = Object.freeze({ flexGrow: 1 });
|
|
36
38
|
|
|
37
39
|
/**
|
|
@@ -47,6 +49,25 @@ const FILL = Object.freeze({ flexGrow: 1 });
|
|
|
47
49
|
* Skip the second and `<ThemeProvider value={dark}>` over
|
|
48
50
|
* `<box style={{ color: '$text' }}>` silently paints nothing (#119).
|
|
49
51
|
*
|
|
52
|
+
* ## Where it goes
|
|
53
|
+
*
|
|
54
|
+
* **Above the windows, or inside one.** At the root it draws nothing and the
|
|
55
|
+
* windows under it take the palette — whatever shape they arrive in: a
|
|
56
|
+
* component that renders one, a window that is closed for now, several at
|
|
57
|
+
* once (#584).
|
|
58
|
+
*
|
|
59
|
+
* ```jsx
|
|
60
|
+
* root.render(
|
|
61
|
+
* <ThemeProvider colorScheme={dark ? 'dark' : 'light'}>
|
|
62
|
+
* <App />
|
|
63
|
+
* </ThemeProvider>,
|
|
64
|
+
* );
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* Inside a window it is a box that fills its parent, with `style` for the
|
|
68
|
+
* rest. A window nested under it is handed on to the window the provider is
|
|
69
|
+
* directly inside, with the palette, since a window nests only in a window.
|
|
70
|
+
*
|
|
50
71
|
* ## What "already in force" means
|
|
51
72
|
*
|
|
52
73
|
* **The desktop's palette.** With no provider at all an app is dark on a dark
|
|
@@ -134,7 +155,13 @@ export function ThemeProvider({
|
|
|
134
155
|
h(
|
|
135
156
|
EnvValue,
|
|
136
157
|
{ k: THEME_ENV_KEY, value: theme },
|
|
137
|
-
planted(
|
|
158
|
+
// The node the palette is planted on (nodes/kinds.js). Written the same
|
|
159
|
+
// wherever the provider is: the renderer knows whether that is inside a
|
|
160
|
+
// window, where it is a box, or above the windows, where it is a node
|
|
161
|
+
// that draws nothing — which a provider could never tell from its
|
|
162
|
+
// children, since a component, a closed window and a fragment all look
|
|
163
|
+
// alike from here (#584).
|
|
164
|
+
h(THEME_SCOPE, { theme, style: boxStyle }, children),
|
|
138
165
|
),
|
|
139
166
|
);
|
|
140
167
|
}
|
|
@@ -149,33 +176,10 @@ export const THEME_ENV_KEY = 'react-x11:theme';
|
|
|
149
176
|
// the complete merged palette, so it wins every token; a pane's own inner
|
|
150
177
|
// ThemeProvider still overrides below it, which is the opt-out a pane that
|
|
151
178
|
// wants its own look already has.
|
|
152
|
-
registerFrameProvider(
|
|
153
|
-
|
|
154
|
-
(value, children) => h(ThemeProvider, { value }, children),
|
|
155
|
-
// directly around the pane's window: `planted` puts the palette on a
|
|
156
|
-
// window among its direct children, and the window is where it must land
|
|
157
|
-
{ innermost: true },
|
|
179
|
+
registerFrameProvider(THEME_ENV_KEY, (value, children) =>
|
|
180
|
+
h(ThemeProvider, { value }, children),
|
|
158
181
|
);
|
|
159
182
|
|
|
160
|
-
/**
|
|
161
|
-
* The node that carries the palette into the tree. Normally a box — but a
|
|
162
|
-
* `<window>` may only be a root child or nested in another window, never
|
|
163
|
-
* inside a box, so a provider above one plants the prop on the windows
|
|
164
|
-
* themselves instead of coming between them. An explicit `theme` on a child
|
|
165
|
-
* still wins, since that is what it means everywhere else.
|
|
166
|
-
*/
|
|
167
|
-
function planted(children, theme, style) {
|
|
168
|
-
const kids = React.Children.toArray(children);
|
|
169
|
-
if (kids.some((k) => React.isValidElement(k) && k.type === 'window')) {
|
|
170
|
-
return kids.map((k) =>
|
|
171
|
-
React.isValidElement(k)
|
|
172
|
-
? React.cloneElement(k, { theme: k.props.theme ?? theme })
|
|
173
|
-
: k,
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
return h('box', { theme, style }, children);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
183
|
/**
|
|
180
184
|
* The palette in force here — already merged over any outer provider, and the
|
|
181
185
|
* same object the provider planted in the tree, so `useTheme()` and a `$token`
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Serving `com.canonical.dbusmenu`: the half of the protocol that is the same
|
|
2
|
+
// wherever the menu is hung.
|
|
3
|
+
//
|
|
4
|
+
// `dbusmenu.js` is the pure part — ids, snapshots, the diff — and says why
|
|
5
|
+
// each of those is hard. This is the object that answers for it on a bus:
|
|
6
|
+
// `GetLayout`, the property reads, `Event`, `AboutToShow`, and the two update
|
|
7
|
+
// signals. Nothing here knows *why* a menu is exported, and that is the point.
|
|
8
|
+
//
|
|
9
|
+
// It was extracted from `GlobalMenuExport` when the tray arrived
|
|
10
|
+
// (react-x11#353), because a panel menu and a tray menu differ only in how the
|
|
11
|
+
// desktop is told where the menu is:
|
|
12
|
+
//
|
|
13
|
+
// - a **global menu** registers a *window* with `AppMenu.Registrar`, and
|
|
14
|
+
// follows the registrar's ownership so the bar moves back into the window
|
|
15
|
+
// when the panel exits (`globalmenu.js`);
|
|
16
|
+
// - a **tray menu** is named by the `Menu` property of a StatusNotifierItem
|
|
17
|
+
// and has no window at all (`statusnotifier.js`).
|
|
18
|
+
//
|
|
19
|
+
// Both serve the identical tree. Subclasses own the bus lifecycle — when to
|
|
20
|
+
// export, what to register, when to withdraw — and inherit everything below.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
DBUSMENU_IFACE,
|
|
24
|
+
PROPERTY_TYPES,
|
|
25
|
+
diffSnapshots,
|
|
26
|
+
groupProperties,
|
|
27
|
+
layoutOf,
|
|
28
|
+
snapshot,
|
|
29
|
+
IdAllocator,
|
|
30
|
+
} from './dbusmenu.js';
|
|
31
|
+
|
|
32
|
+
/** dbusmenu's recursive layout struct: `(id, properties, children)`. */
|
|
33
|
+
export const LAYOUT_SIGNATURE = '(ia{sv}av)';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A menu tree, serialised and served.
|
|
37
|
+
*
|
|
38
|
+
* `getMenus()` is read at construction and on every `update()`; `onSelect` and
|
|
39
|
+
* `onAboutToShow` receive the *item object* the app authored, not an id, so a
|
|
40
|
+
* caller never deals in dbusmenu's numbering.
|
|
41
|
+
*/
|
|
42
|
+
export class DbusMenuExport {
|
|
43
|
+
constructor({ getMenus, onSelect, onAboutToShow }) {
|
|
44
|
+
this.getMenus = getMenus;
|
|
45
|
+
this.onSelect = onSelect;
|
|
46
|
+
this.onAboutToShow = onAboutToShow;
|
|
47
|
+
|
|
48
|
+
this.alloc = new IdAllocator();
|
|
49
|
+
this.nodes = snapshot(getMenus(), this.alloc);
|
|
50
|
+
this.revision = 1;
|
|
51
|
+
|
|
52
|
+
/** Set by the subclass once the object is actually on a bus: until then
|
|
53
|
+
* `update()` must not emit, because nobody is listening and the
|
|
54
|
+
* revision would run ahead of the first `GetLayout`. */
|
|
55
|
+
this.exported = false;
|
|
56
|
+
this.iface = null;
|
|
57
|
+
this.dbus = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ----------------------------------------------------------------- update
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A new `menus` array. Serialise, diff, and send the *narrower* of the two
|
|
64
|
+
* signals the protocol has — see `diffSnapshots`, where the choice is made
|
|
65
|
+
* and why it matters.
|
|
66
|
+
*/
|
|
67
|
+
update(menus) {
|
|
68
|
+
const next = snapshot(menus, this.alloc);
|
|
69
|
+
const change = diffSnapshots(this.nodes, next);
|
|
70
|
+
this.nodes = next;
|
|
71
|
+
if (!this.exported || change.kind === 'none') return;
|
|
72
|
+
|
|
73
|
+
if (change.kind === 'properties') {
|
|
74
|
+
this.iface?.emit.ItemsPropertiesUpdated(
|
|
75
|
+
change.updated.map(([id, props]) => [id, this.wrap(props)]),
|
|
76
|
+
change.removed,
|
|
77
|
+
);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.revision += 1;
|
|
81
|
+
this.iface?.emit.LayoutUpdated(this.revision, change.parent);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Plain property values → the `a{sv}` the wire wants. */
|
|
85
|
+
wrap(props) {
|
|
86
|
+
const out = {};
|
|
87
|
+
for (const [name, value] of Object.entries(props)) {
|
|
88
|
+
const type = PROPERTY_TYPES[name];
|
|
89
|
+
if (type) out[name] = new this.dbus.Variant(type, value);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `layoutOf`'s plain tree → the recursive `(ia{sv}av)`. */
|
|
95
|
+
wrapLayout(node) {
|
|
96
|
+
const [id, props, children] = node;
|
|
97
|
+
return [
|
|
98
|
+
id,
|
|
99
|
+
this.wrap(props),
|
|
100
|
+
children.map(
|
|
101
|
+
(child) =>
|
|
102
|
+
new this.dbus.Variant(LAYOUT_SIGNATURE, this.wrapLayout(child)),
|
|
103
|
+
),
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --------------------------------------------------------------- protocol
|
|
108
|
+
|
|
109
|
+
itemFor(id) {
|
|
110
|
+
return this.nodes.get(id)?.item ?? null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
defineMenu(dbus) {
|
|
114
|
+
this.dbus = dbus;
|
|
115
|
+
const fire = (id, eventId) => {
|
|
116
|
+
const item = this.itemFor(id);
|
|
117
|
+
if (!item) return;
|
|
118
|
+
if (eventId === 'clicked') this.onSelect?.(item);
|
|
119
|
+
else if (eventId === 'opened') this.onAboutToShow?.(item);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return dbus.defineInterface({
|
|
123
|
+
name: DBUSMENU_IFACE,
|
|
124
|
+
methods: {
|
|
125
|
+
GetLayout: {
|
|
126
|
+
in: { parentId: 'i', recursionDepth: 'i', propertyNames: 'as' },
|
|
127
|
+
out: { revision: 'u', layout: LAYOUT_SIGNATURE },
|
|
128
|
+
handler: ({ parentId, recursionDepth, propertyNames }) => {
|
|
129
|
+
const tree = layoutOf(
|
|
130
|
+
this.nodes,
|
|
131
|
+
parentId,
|
|
132
|
+
recursionDepth ?? -1,
|
|
133
|
+
propertyNames,
|
|
134
|
+
);
|
|
135
|
+
// An id the shell remembers from before a structural change is the
|
|
136
|
+
// normal way to arrive here, not a protocol violation: answer with
|
|
137
|
+
// an empty item rather than an error, and let the LayoutUpdated it
|
|
138
|
+
// has already been sent bring it back for the real tree.
|
|
139
|
+
return {
|
|
140
|
+
revision: this.revision,
|
|
141
|
+
layout: this.wrapLayout(tree ?? [parentId, {}, []]),
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
GetGroupProperties: {
|
|
146
|
+
in: { ids: 'ai', propertyNames: 'as' },
|
|
147
|
+
out: { properties: 'a(ia{sv})' },
|
|
148
|
+
handler: ({ ids, propertyNames }) =>
|
|
149
|
+
groupProperties(this.nodes, ids, propertyNames).map(
|
|
150
|
+
([id, props]) => [id, this.wrap(props)],
|
|
151
|
+
),
|
|
152
|
+
},
|
|
153
|
+
GetProperty: {
|
|
154
|
+
in: { id: 'i', name: 's' },
|
|
155
|
+
out: { value: 'v' },
|
|
156
|
+
handler: ({ id, name }) => {
|
|
157
|
+
const props = this.nodes.get(id)?.props;
|
|
158
|
+
// `hasOwn` on both, so a name like `constructor` reads as absent
|
|
159
|
+
// rather than as a function that then fails to marshal.
|
|
160
|
+
const known = Object.hasOwn(PROPERTY_TYPES, name);
|
|
161
|
+
const value =
|
|
162
|
+
props && Object.hasOwn(props, name) ? props[name] : undefined;
|
|
163
|
+
// A property this item does not carry is at its default, and the
|
|
164
|
+
// spec's own advice is to answer with it rather than to error.
|
|
165
|
+
if (value === undefined || !known) return new dbus.Variant('s', '');
|
|
166
|
+
return new dbus.Variant(PROPERTY_TYPES[name], value);
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
Event: {
|
|
170
|
+
in: { id: 'i', eventId: 's', data: 'v', timestamp: 'u' },
|
|
171
|
+
out: {},
|
|
172
|
+
handler: ({ id, eventId }) => fire(id, eventId),
|
|
173
|
+
},
|
|
174
|
+
EventGroup: {
|
|
175
|
+
in: { events: 'a(isvu)' },
|
|
176
|
+
out: { idErrors: 'ai' },
|
|
177
|
+
handler: ({ events }) => {
|
|
178
|
+
const errors = [];
|
|
179
|
+
for (const [id, eventId] of events ?? []) {
|
|
180
|
+
if (this.itemFor(id)) fire(id, eventId);
|
|
181
|
+
else errors.push(id);
|
|
182
|
+
}
|
|
183
|
+
return errors;
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
AboutToShow: {
|
|
187
|
+
in: { id: 'i' },
|
|
188
|
+
out: { needUpdate: 'b' },
|
|
189
|
+
handler: ({ id }) => this.aboutToShow(id),
|
|
190
|
+
},
|
|
191
|
+
AboutToShowGroup: {
|
|
192
|
+
in: { ids: 'ai' },
|
|
193
|
+
out: { updatesNeeded: 'ai', idErrors: 'ai' },
|
|
194
|
+
handler: ({ ids }) => {
|
|
195
|
+
const errors = [];
|
|
196
|
+
for (const id of ids ?? []) {
|
|
197
|
+
if (this.itemFor(id)) this.aboutToShow(id);
|
|
198
|
+
else errors.push(id);
|
|
199
|
+
}
|
|
200
|
+
return { updatesNeeded: [], idErrors: errors };
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
properties: {
|
|
205
|
+
Version: { type: 'u', access: 'read', get: () => 3 },
|
|
206
|
+
Status: { type: 's', access: 'read', get: () => 'normal' },
|
|
207
|
+
TextDirection: { type: 's', access: 'read', get: () => 'ltr' },
|
|
208
|
+
// Empty, and not a placeholder: `icon-name` is looked up in the
|
|
209
|
+
// desktop's own theme, which is where an app's icons should come from.
|
|
210
|
+
// A path here would be for icons shipped beside the app.
|
|
211
|
+
IconThemePath: { type: 'as', access: 'read', get: () => [] },
|
|
212
|
+
},
|
|
213
|
+
signals: {
|
|
214
|
+
LayoutUpdated: { args: { revision: 'u', parent: 'i' } },
|
|
215
|
+
ItemsPropertiesUpdated: {
|
|
216
|
+
args: { updated: 'a(ia{sv})', removed: 'a(ias)' },
|
|
217
|
+
},
|
|
218
|
+
ItemActivationRequested: { args: { id: 'i', timestamp: 'u' } },
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The reply has to go out **now**, and React has not rendered yet.
|
|
225
|
+
*
|
|
226
|
+
* `setState` from here does not produce a new tree before this function
|
|
227
|
+
* returns, so there is no honest way to answer `true` — and answering `true`
|
|
228
|
+
* dishonestly is worse than answering `false`: the shell then blocks on a
|
|
229
|
+
* `GetLayout` for a subtree that has not been built, gets the old one, and
|
|
230
|
+
* caches it.
|
|
231
|
+
*
|
|
232
|
+
* So: answer `false`, let the handler run, and let the ordinary update path
|
|
233
|
+
* emit `LayoutUpdated` when the new items actually serialise. Shells listen
|
|
234
|
+
* for that unconditionally — it is the same signal a menu changing while
|
|
235
|
+
* open produces — so a lazily-built submenu still fills in, one round trip
|
|
236
|
+
* later than a synchronous toolkit would manage.
|
|
237
|
+
*/
|
|
238
|
+
aboutToShow(id) {
|
|
239
|
+
const item = this.itemFor(id);
|
|
240
|
+
if (item) this.onAboutToShow?.(item);
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|