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.
Files changed (64) hide show
  1. package/README.md +37 -0
  2. package/package.json +4 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/anchor.js +60 -18
  5. package/src/application.js +25 -1
  6. package/src/capabilities.js +349 -0
  7. package/src/cocoa/app.js +28 -9
  8. package/src/cocoa/context2d.js +139 -6
  9. package/src/cocoa/fonts.js +78 -0
  10. package/src/cocoa/presenter.js +17 -0
  11. package/src/cocoa/promotion.js +20 -0
  12. package/src/cocoa/relaunch.js +8 -3
  13. package/src/cocoa/symbols.js +64 -0
  14. package/src/cocoa/threaded.js +24 -4
  15. package/src/cocoa/window.js +362 -139
  16. package/src/components/ProgressBar.js +1 -1
  17. package/src/components/Slider.js +72 -39
  18. package/src/components/anchor.js +7 -2
  19. package/src/components/index.js +1 -0
  20. package/src/components/theme.js +32 -28
  21. package/src/dbusmenuexport.js +243 -0
  22. package/src/desktopcapabilityhooks.js +160 -0
  23. package/src/filedialoghooks.js +3 -5
  24. package/src/frame/childmain.js +8 -20
  25. package/src/frame/env.js +2 -10
  26. package/src/globalmenu.js +3 -205
  27. package/src/icontheme.js +240 -0
  28. package/src/imagesource.js +98 -3
  29. package/src/index.d.ts +1 -0
  30. package/src/index.js +11 -2
  31. package/src/launcher.js +235 -32
  32. package/src/launcherhooks.js +47 -28
  33. package/src/node.d.ts +7 -0
  34. package/src/nodes/animation.js +17 -47
  35. package/src/nodes/cascade.js +17 -2
  36. package/src/nodes/image.js +65 -2
  37. package/src/nodes/kinds.js +12 -0
  38. package/src/nodes/layout.js +5 -1
  39. package/src/nodes/node.js +17 -3
  40. package/src/nodes/paint.js +117 -0
  41. package/src/nodes/scope.js +259 -0
  42. package/src/nodes/scrollable.js +53 -6
  43. package/src/nodes/text.js +2 -0
  44. package/src/nodes/textarea.js +1 -1
  45. package/src/nodes/textinput.js +1 -1
  46. package/src/nodes/window/anchoring.js +45 -18
  47. package/src/nodes/window/flush.js +6 -5
  48. package/src/nodes/window/popup.js +10 -0
  49. package/src/nodes/window/size.js +40 -2
  50. package/src/nodes/window/window.js +41 -14
  51. package/src/registry.js +2 -1
  52. package/src/settings.js +332 -0
  53. package/src/statusnotifier.js +752 -0
  54. package/src/styles.js +212 -8
  55. package/src/symbols.js +200 -0
  56. package/src/testing/mock-app.js +10 -0
  57. package/src/trayhooks.js +193 -29
  58. package/src/types/capabilities.d.ts +139 -0
  59. package/src/types/components.d.ts +33 -0
  60. package/src/types/elements.d.ts +57 -6
  61. package/src/types/launcher.d.ts +50 -4
  62. package/src/types/style.d.ts +57 -0
  63. package/src/types/system.d.ts +104 -0
  64. 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 track as its damage every frame
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
@@ -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
- const SLIDER_SLOP = (24 - SLIDER_THUMB) / 2;
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 nativeControls = useNativeControls(native);
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 - SLIDER_THUMB);
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 - SLIDER_THUMB / 2
95
- : ev.x - rect.x - SLIDER_THUMB / 2;
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: SLIDER_THUMB,
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: SLIDER_SLOP, bottom: SLIDER_SLOP },
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
- height: height,
248
- borderRadius: height / 2,
249
- backgroundColor: theme.track,
250
- flexDirection: 'row',
251
- alignItems: 'center',
252
- pointerEvents: 'none',
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
- flexGrow: fraction,
263
- flexShrink: 0,
264
- flexBasis: 0,
265
- height: height,
266
- borderRadius: height / 2,
267
- backgroundColor: disabled ? theme.textMuted : theme.accent,
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
- position: 'absolute',
281
- start: `${fraction * 100}%`,
282
- marginStart: -SLIDER_THUMB * fraction,
283
- width: SLIDER_THUMB,
284
- height: SLIDER_THUMB,
285
- borderRadius: SLIDER_THUMB / 2,
286
- borderWidth: 1,
287
- borderColor: disabled
288
- ? theme.border
289
- : focused || dragging
290
- ? theme.accentHover
291
- : theme.border,
292
- backgroundColor: disabled ? theme.surfaceHover : theme.surface,
293
- pointerEvents: 'none',
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
  }
@@ -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. An explicit `style` still wins, since a caller that names a
181
- // face is measuring something it is about to draw in that face.
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
  }
@@ -6,6 +6,7 @@ export { ThemeProvider, useDirection, useTheme } from './theme.js';
6
6
  export {
7
7
  anchorArea,
8
8
  anchorRect,
9
+ anchorScreenRect,
9
10
  centerRect,
10
11
  screenRect,
11
12
  useAnchor,
@@ -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
- // The provider's box fills its parent, which is what an app-level provider
34
- // wants; `style` is there for the ones that wrap a single control.
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(children, theme, boxStyle),
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
- THEME_ENV_KEY,
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
+ }