react-x11 1.2.0 → 2.0.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 (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. package/src/scene3d.js +0 -683
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Is a compositing manager running?
3
+ *
4
+ * It matters because per-pixel transparency is not something the X server
5
+ * does. A depth-32 window's alpha channel is only meaningful to a compositor
6
+ * that blends it; with nothing composited, the server shows the raw pixels
7
+ * and a cleared corner is a *black* corner. So `<window transparent>` on a
8
+ * bare `startx`, a plain remote X server, or a session whose compositor was
9
+ * switched off looks worse than a square opaque window, not better.
10
+ *
11
+ * EWMH names the answer: the owner of the `_NET_WM_CM_Sn` selection, one per
12
+ * screen. Asking is one round trip, and it is asked once per connection
13
+ * during `createRoot`, which is already async — so by the time any window
14
+ * realizes, the answer is known synchronously and nothing has to block.
15
+ *
16
+ * The answer also changes while an app runs: compositors are started and
17
+ * stopped, and on some desktops that is a checkbox. XFixes reports it —
18
+ * `SelectSelectionInput` on the same selection — which is how this stays
19
+ * live rather than being a startup guess. That matters more than it sounds:
20
+ * a window's *visual* is fixed at CreateWindow and cannot follow, but
21
+ * whether the window paints its alpha channel or fills itself opaque is a
22
+ * paint-time decision, and paint-time decisions can change.
23
+ */
24
+
25
+ import { requireExtension } from './extensions.js';
26
+
27
+ const sessions = new WeakMap();
28
+
29
+ /**
30
+ * What a connection currently knows. `supported` is null until the first
31
+ * probe answers — callers treat that as "no", because the fallback design is
32
+ * the one that works everywhere and a popup that flashes from square to
33
+ * rounded is better than one that flashes black.
34
+ */
35
+ class CompositingSession {
36
+ constructor(app) {
37
+ this.app = app;
38
+ this.supported = null;
39
+ this.listeners = new Set();
40
+ this.stopped = false;
41
+ this._window = null;
42
+ this._selection = null;
43
+ }
44
+
45
+ _set(supported) {
46
+ const next = Boolean(supported);
47
+ if (this.supported === next) return;
48
+ this.supported = next;
49
+ for (const fn of [...this.listeners]) {
50
+ try {
51
+ fn(next);
52
+ } catch {
53
+ // a subscriber that throws must not take the others with it, nor
54
+ // the X event loop this runs on
55
+ }
56
+ }
57
+ }
58
+
59
+ subscribe(fn) {
60
+ this.listeners.add(fn);
61
+ return () => this.listeners.delete(fn);
62
+ }
63
+
64
+ stop() {
65
+ this.stopped = true;
66
+ this.listeners.clear();
67
+ }
68
+ }
69
+
70
+ /** The atom name for a screen's compositing-manager selection. */
71
+ const selectionName = (screen) => `_NET_WM_CM_S${screen}`;
72
+
73
+ /**
74
+ * Start watching. Resolves once the first answer is in, so `createRoot` can
75
+ * await it and every window realized afterwards sees a settled value.
76
+ * Never rejects: a server without XFixes, or a mock with no X connection at
77
+ * all, simply reports "no compositor" and stops there.
78
+ */
79
+ export async function beginCompositing(app, screen = 0) {
80
+ let session = sessions.get(app);
81
+ if (session) return session;
82
+ session = new CompositingSession(app);
83
+ sessions.set(app, session);
84
+
85
+ const X = app?.X;
86
+ if (!X || typeof X.GetSelectionOwner !== 'function') {
87
+ // headless mock, or an ntk too old to reach the raw connection
88
+ session._set(false);
89
+ return session;
90
+ }
91
+
92
+ try {
93
+ const atom = await internAtom(X, selectionName(screen));
94
+ session._selection = atom;
95
+ session._set(await selectionOwner(X, atom));
96
+ // Live updates are a bonus, not a requirement: without XFixes the
97
+ // startup answer simply stands for the life of the connection — which
98
+ // is also why the watch is not waited for. `supported` is settled by
99
+ // the line above, and nothing before the first realize reads the watch,
100
+ // so awaiting the XFixes probe here only lengthens the chain the first
101
+ // CreateWindow sits behind.
102
+ void watchSelection(app, session, atom).catch(() => {});
103
+ } catch {
104
+ if (session.supported === null) session._set(false);
105
+ }
106
+ return session;
107
+ }
108
+
109
+ function internAtom(X, name) {
110
+ return new Promise((resolve, reject) =>
111
+ X.InternAtom(false, name, (err, atom) =>
112
+ err ? reject(err) : resolve(atom),
113
+ ),
114
+ );
115
+ }
116
+
117
+ function selectionOwner(X, atom) {
118
+ return new Promise((resolve, reject) =>
119
+ X.GetSelectionOwner(atom, (err, owner) =>
120
+ // owner 0 is "nobody", which is exactly "no compositor"
121
+ err ? reject(err) : resolve(Boolean(owner)),
122
+ ),
123
+ );
124
+ }
125
+
126
+ /**
127
+ * XFixes selection notifications, delivered to a window we own. One event
128
+ * covers both directions: `owner` is the compositor's window when one takes
129
+ * over, and 0 when it goes away, whether it released the selection, closed
130
+ * the window or died.
131
+ */
132
+ async function watchSelection(app, session, atom) {
133
+ const fixes = await requireExtension(app, 'fixes');
134
+ if (!fixes || session.stopped) return;
135
+ const X = app.X;
136
+ // A 1x1 InputOnly window off-screen, never mapped: XFixes addresses the
137
+ // notifications to a window, and this one exists only to be that address.
138
+ const id = X.AllocID();
139
+ X.CreateWindow(id, app.display.screen[0].root, -10, -10, 1, 1, 0, 0, 2, 0, {
140
+ eventMask: 0,
141
+ });
142
+ session._window = id;
143
+ const mask =
144
+ fixes.SelectionEventMask.SetSelectionOwner |
145
+ fixes.SelectionEventMask.SelectionWindowDestroy |
146
+ fixes.SelectionEventMask.SelectionClientClose;
147
+ X.on('event', (ev) => {
148
+ if (session.stopped) return;
149
+ // XFixes events carry a server-assigned type above the core range, so
150
+ // this cannot be confused with core SelectionNotify (31)
151
+ if (ev.type !== fixes.firstEvent) return;
152
+ if (ev.selection !== atom) return;
153
+ session._set(Boolean(ev.owner));
154
+ });
155
+ fixes.SelectSelectionInput(id, atom, mask);
156
+ }
157
+
158
+ /** Is a compositor running on this connection? False while unknown. */
159
+ export function compositingActive(app) {
160
+ return sessions.get(app)?.supported === true;
161
+ }
162
+
163
+ /**
164
+ * Is transparency switched off for this process?
165
+ *
166
+ * `REACT_X11_NO_TRANSPARENCY=1` makes every display answer the way one with
167
+ * no 32-bit visual does: `transparent` is ignored, windows are created on
168
+ * the ordinary visual, and `'@supports transparency'` never matches. It is
169
+ * for looking at the fallback design on the machine you actually work on —
170
+ * the built-in menus and tooltips have two looks now, and the opaque one is
171
+ * otherwise reachable only by stopping the compositor for the whole session,
172
+ * which takes every other window on the desktop with it.
173
+ *
174
+ * `=== '1'` rather than a truthy test, like `REACT_X11_NO_PAINT_CACHE`: a
175
+ * stale `NO_TRANSPARENCY=0` left in a shell profile must not silently mean
176
+ * the opposite of what it says. Read per call rather than once at load, so
177
+ * a test can turn it on around one mount — and because the question it
178
+ * feeds is already one whose answer may change while an app runs.
179
+ */
180
+ export function transparencyDisabled() {
181
+ return process.env.REACT_X11_NO_TRANSPARENCY === '1';
182
+ }
183
+
184
+ /**
185
+ * The 32-bit visual a transparent window would be created on, or null when
186
+ * this display has none — and null whatever it has when transparency is
187
+ * switched off. The one place that question is asked, so the switch cannot
188
+ * be half-applied: a window that took the visual but a `useSupports` that
189
+ * said no would size a tooltip for an arrow it then refused to draw.
190
+ */
191
+ export function argbVisual(app) {
192
+ if (transparencyDisabled()) return null;
193
+ return app?.findArgbVisual?.() ?? null;
194
+ }
195
+
196
+ /** Subscribe to the answer changing. Returns an unsubscribe function. */
197
+ export function watchCompositing(app, fn) {
198
+ const session = sessions.get(app);
199
+ if (!session) return () => {};
200
+ return session.subscribe(fn);
201
+ }
202
+
203
+ /** Tear down with the root that started it. */
204
+ export function endCompositing(app) {
205
+ const session = sessions.get(app);
206
+ if (!session) return;
207
+ session.stop();
208
+ sessions.delete(app);
209
+ }
210
+
211
+ /**
212
+ * Test seam: force the answer without an X server, the way `setDebugPaint`
213
+ * and the animation clock are driven from tests. Also what the headless mock
214
+ * uses to model a display with or without a compositor.
215
+ */
216
+ export function setCompositingForTests(app, supported) {
217
+ let session = sessions.get(app);
218
+ if (!session) {
219
+ session = new CompositingSession(app);
220
+ sessions.set(app, session);
221
+ }
222
+ session._set(supported);
223
+ return session;
224
+ }
@@ -0,0 +1,384 @@
1
+ // `com.canonical.dbusmenu`, the exporter half.
2
+ //
3
+ // The protocol is a **stateful, id-addressed, revision-numbered tree**. React
4
+ // hands us a fresh immutable array every render. Everything in this file is
5
+ // the join between those two facts, and it is three problems that a C toolkit
6
+ // holding a mutable `GtkMenuBar` simply does not have.
7
+ //
8
+ // **1. Ids must survive a re-render.** The shell caches properties against
9
+ // integer ids, so an id that moved to a different row makes the panel draw
10
+ // yesterday's label on today's item. `IdAllocator` keys on the path of
11
+ // `key ?? label` segments — the same identity `MenuRow` already uses for its
12
+ // React key — and never reuses a number within a process.
13
+ //
14
+ // **2. The diff picks the signal, and that choice is a performance decision.**
15
+ // Plasma re-fetches a whole subtree on `LayoutUpdated` and only patches on
16
+ // `ItemsPropertiesUpdated`. So a menu whose single `toggleState` flipped must
17
+ // **not** bump the revision — which is exactly what a naive
18
+ // `setState` → `LayoutUpdated(++rev, 0)` does on every keystroke, and it turns
19
+ // one property write into a full re-walk of every menu in the app.
20
+ //
21
+ // **3. `AboutToShow` wants a synchronous boolean React cannot give.** See
22
+ // `aboutToShow` below.
23
+ //
24
+ // Nothing here touches D-Bus. `snapshot()` and `diffSnapshots()` are pure, so
25
+ // the parts that are easy to get wrong are testable with no bus, no X server
26
+ // and no React — which is what `test/dbusmenu.test.js` does. The wire lives in
27
+ // `globalmenu.js`.
28
+
29
+ import {
30
+ hasSubmenu,
31
+ isSeparator,
32
+ isValidShortcut,
33
+ isVisible,
34
+ } from './menuitem.js';
35
+
36
+ export const DBUSMENU_IFACE = 'com.canonical.dbusmenu';
37
+
38
+ /** The root of a dbusmenu tree is always id 0, and is never an item. */
39
+ export const ROOT_ID = 0;
40
+
41
+ /**
42
+ * The D-Bus type of every item property we emit.
43
+ *
44
+ * A table rather than inline variants because two places need to agree on it:
45
+ * the layout serialiser and `ItemsPropertiesUpdated`. It is also the list of
46
+ * properties `GetLayout(…, propertyNames)` can filter on, so a shell asking
47
+ * for `['label']` gets exactly the labels.
48
+ */
49
+ export const PROPERTY_TYPES = {
50
+ type: 's',
51
+ label: 's',
52
+ enabled: 'b',
53
+ visible: 'b',
54
+ 'icon-name': 's',
55
+ 'icon-data': 'ay',
56
+ shortcut: 'aas',
57
+ 'toggle-type': 's',
58
+ 'toggle-state': 'i',
59
+ 'children-display': 's',
60
+ disposition: 's',
61
+ };
62
+
63
+ // dbusmenu defines a default for these, and the spec is explicit that a
64
+ // property at its default **should not be sent**. It is not only traffic: a
65
+ // shell diffing properties it was told about will happily store `enabled:true`
66
+ // on ten thousand items that never said anything of the sort.
67
+ const DEFAULTS = {
68
+ type: 'standard',
69
+ enabled: true,
70
+ visible: true,
71
+ 'toggle-state': 0,
72
+ disposition: 'normal',
73
+ };
74
+
75
+ /**
76
+ * The dbusmenu properties for one item, defaults omitted.
77
+ *
78
+ * `icon` is deliberately absent: it is a react-x11 drawing callback, and the
79
+ * desktop cannot call a function. An item that wants an icon in *both* menus
80
+ * carries `icon` for ours and `iconName`/`iconData` for the panel's.
81
+ */
82
+ export function itemProperties(item) {
83
+ const props = {};
84
+ const set = (name, value) => {
85
+ if (value === undefined || value === null) return;
86
+ if (DEFAULTS[name] === value) return;
87
+ props[name] = value;
88
+ };
89
+
90
+ if (isSeparator(item)) {
91
+ // A separator has nothing else to say, and a label on one is a shell bug
92
+ // waiting to happen — some panels draw it.
93
+ set('type', 'separator');
94
+ set('visible', isVisible(item));
95
+ return props;
96
+ }
97
+
98
+ set('label', item.label);
99
+ set('enabled', item.enabled);
100
+ set('visible', item.visible);
101
+ set('icon-name', item.iconName);
102
+ set('icon-data', item.iconData);
103
+ set('toggle-type', item.toggleType);
104
+ set('toggle-state', item.toggleState);
105
+ set('disposition', item.disposition);
106
+ // Only ever a claim about children that exist. A parent that promises a
107
+ // submenu and then answers `GetLayout` with none leaves an arrow pointing
108
+ // at an empty popup.
109
+ if (hasSubmenu(item)) props['children-display'] = 'submenu';
110
+ // A malformed shortcut is dropped rather than sent. dbus-native would throw
111
+ // from inside the marshaller — that is mid-reply, on the bus thread, where
112
+ // the only symptom is a panel with no menu at all. `checkShortcut` is what
113
+ // tells the developer, in development, at the item that is wrong.
114
+ if (isValidShortcut(item.shortcut)) props.shortcut = item.shortcut;
115
+
116
+ return props;
117
+ }
118
+
119
+ // --------------------------------------------------------------------------
120
+ // Stable ids
121
+ // --------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Integer ids that survive re-renders.
125
+ *
126
+ * An item is identified by its `key ?? label` within its parent — the same
127
+ * identity `MenuRow` keys its React element on, so an app that already gave
128
+ * its items stable keys gets stable ids for free and one that did not gets
129
+ * exactly the same answer both places.
130
+ *
131
+ * Three rules that are not obvious:
132
+ *
133
+ * - **Ids are never reused.** A menu that loses an item and later grows a
134
+ * different one at the same path must not hand the shell an id it already
135
+ * has properties cached against. So the counter only ever goes up, and a
136
+ * forgotten path is forgotten for good.
137
+ * - **Separators are keyed among separators**, not by their index in the
138
+ * list. Keying them by list position renumbers every separator below an
139
+ * inserted item, which turns a one-row insertion into a structural change
140
+ * across the whole menu.
141
+ * - **Identity is scoped to the parent's id, not to a joined string path.**
142
+ * See `idFor`.
143
+ */
144
+ export class IdAllocator {
145
+ constructor() {
146
+ this.ids = new Map();
147
+ this.next = ROOT_ID + 1;
148
+ }
149
+
150
+ /**
151
+ * The id for `segment` under `parentId`, allocated on first sight.
152
+ *
153
+ * Keyed against the parent's **id** rather than against a joined string
154
+ * path, because labels are arbitrary text and any character chosen to join
155
+ * a path is one a label can contain — with `/`, an item called `a/b` under
156
+ * `p` collides with `b` under `p/a`. A parent's id is already a unique
157
+ * integer, so there is nothing to escape.
158
+ */
159
+ idFor(parentId, segment) {
160
+ const key = `${parentId}\u0000${segment}`;
161
+ let id = this.ids.get(key);
162
+ if (id === undefined) {
163
+ id = this.next++;
164
+ this.ids.set(key, id);
165
+ }
166
+ return id;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Sibling path segments, each unique within its list.
172
+ *
173
+ * The delimiter is a NUL, written as a `\u0000` **escape** rather than as
174
+ * a literal byte: a NUL in the source makes git treat the file as binary
175
+ * — no diff, no blame, no three-way merge, on the file holding the
176
+ * trickiest logic in the feature — and any tool that normalises it would
177
+ * silently change the disambiguator.
178
+ *
179
+ * The occurrence count goes on **every** segment rather than only on the
180
+ * duplicates, which is what makes a collision impossible rather than merely
181
+ * unlikely. Appending only to the duplicates looks equivalent and is not:
182
+ * three siblings labelled `A`, `A` and `A 1` produce `A`, `A 1`, `A 1`, so
183
+ * the last two share an id — one row overwrites the other and the panel is
184
+ * handed a tree with the same id in it twice. With the count always present
185
+ * the delimiter splits base from count unambiguously, and two distinct items
186
+ * cannot agree.
187
+ */
188
+ function segmentsFor(items) {
189
+ const seen = new Map();
190
+ let separators = 0;
191
+ return items.map((item, index) => {
192
+ const base = isSeparator(item)
193
+ ? `\u0000sep${separators++}`
194
+ : String(item.key ?? item.label ?? `\u0000at${index}`);
195
+ const n = seen.get(base) ?? 0;
196
+ seen.set(base, n + 1);
197
+ return `${base}\u0000${n}`;
198
+ });
199
+ }
200
+
201
+ // --------------------------------------------------------------------------
202
+ // Snapshots
203
+ // --------------------------------------------------------------------------
204
+
205
+ /**
206
+ * The whole menu as `Map<id, { id, parent, props, childIds, item }>`, which is
207
+ * both what the D-Bus methods answer from and what the next render is diffed
208
+ * against.
209
+ *
210
+ * `menus` is `MenuBar`'s own prop — `[{ label, items }]` — so a bar menu is
211
+ * just an item with children, which is exactly what dbusmenu's root is.
212
+ */
213
+ export function snapshot(menus, alloc) {
214
+ const nodes = new Map();
215
+
216
+ const walk = (items, parentId) => {
217
+ const segments = segmentsFor(items);
218
+ const childIds = [];
219
+ items.forEach((item, index) => {
220
+ const id = alloc.idFor(parentId, segments[index]);
221
+ childIds.push(id);
222
+ nodes.set(id, {
223
+ id,
224
+ parent: parentId,
225
+ props: itemProperties(item),
226
+ childIds: walk(item.items ?? [], id),
227
+ item,
228
+ });
229
+ });
230
+ return childIds;
231
+ };
232
+
233
+ nodes.set(ROOT_ID, {
234
+ id: ROOT_ID,
235
+ parent: null,
236
+ props: { 'children-display': 'submenu' },
237
+ childIds: walk(menus ?? [], ROOT_ID),
238
+ item: null,
239
+ });
240
+ return nodes;
241
+ }
242
+
243
+ const sameIds = (a, b) =>
244
+ a.length === b.length && a.every((v, i) => v === b[i]);
245
+
246
+ /** Property equality, deep enough for the values dbusmenu carries. */
247
+ function sameValue(a, b) {
248
+ if (a === b) return true;
249
+ if (Array.isArray(a) && Array.isArray(b)) {
250
+ return a.length === b.length && a.every((v, i) => sameValue(v, b[i]));
251
+ }
252
+ // Buffers, for icon-data
253
+ if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) {
254
+ return Buffer.from(a.buffer, a.byteOffset, a.byteLength).equals(
255
+ Buffer.from(b.buffer, b.byteOffset, b.byteLength),
256
+ );
257
+ }
258
+ return false;
259
+ }
260
+
261
+ /** The lowest node that has every one of `ids` beneath it (or at it). */
262
+ function nearestCommonAncestor(nodes, ids) {
263
+ if (ids.length === 0) return ROOT_ID;
264
+ const chainOf = (id) => {
265
+ const out = [];
266
+ for (let cur = id; cur != null; cur = nodes.get(cur)?.parent)
267
+ out.unshift(cur);
268
+ return out;
269
+ };
270
+ let common = chainOf(ids[0]);
271
+ for (const id of ids.slice(1)) {
272
+ const chain = chainOf(id);
273
+ let i = 0;
274
+ while (i < common.length && i < chain.length && common[i] === chain[i]) i++;
275
+ common = common.slice(0, i);
276
+ if (common.length === 0) break;
277
+ }
278
+ return common.length ? common[common.length - 1] : ROOT_ID;
279
+ }
280
+
281
+ /**
282
+ * What to tell the shell about the step from `prev` to `next`.
283
+ *
284
+ * ```
285
+ * { kind: 'none' }
286
+ * { kind: 'properties', updated: [[id, props]], removed: [[id, names]] }
287
+ * { kind: 'layout', parent: id }
288
+ * ```
289
+ *
290
+ * **The whole point is that `'properties'` does not bump the revision.**
291
+ * Structure — an item appearing, disappearing or moving — is the only thing
292
+ * that invalidates a cached layout; a label, a check mark or an `enabled`
293
+ * flag is a patch, and sending `LayoutUpdated` for one costs a full re-walk
294
+ * of the subtree in every shell that implements the protocol properly.
295
+ *
296
+ * `parent` is the nearest common ancestor of the subtrees that actually
297
+ * changed, so a change inside one menu does not invalidate the other five.
298
+ * `0` is the spec's "the whole layout is invalid" and stays available as the
299
+ * honest answer when changes are scattered.
300
+ */
301
+ export function diffSnapshots(prev, next) {
302
+ if (!prev) return { kind: 'layout', parent: ROOT_ID };
303
+
304
+ const structural = [];
305
+ for (const [id, node] of next) {
306
+ const before = prev.get(id);
307
+ if (!before) continue; // its parent's childIds changed; that is the signal
308
+ if (!sameIds(before.childIds, node.childIds)) structural.push(id);
309
+ }
310
+ // A node that vanished entirely and whose parent also vanished is covered by
311
+ // the surviving ancestor above. One whose parent survived is covered by that
312
+ // parent's childIds. So `structural` is empty only if the shape is identical.
313
+ if (structural.length > 0) {
314
+ return { kind: 'layout', parent: nearestCommonAncestor(next, structural) };
315
+ }
316
+
317
+ const updated = [];
318
+ const removed = [];
319
+ for (const [id, node] of next) {
320
+ const before = prev.get(id);
321
+ if (!before) continue;
322
+ const changed = {};
323
+ const gone = [];
324
+ for (const [name, value] of Object.entries(node.props)) {
325
+ if (!sameValue(before.props[name], value)) changed[name] = value;
326
+ }
327
+ for (const name of Object.keys(before.props)) {
328
+ if (!(name in node.props)) gone.push(name);
329
+ }
330
+ if (Object.keys(changed).length > 0) updated.push([id, changed]);
331
+ if (gone.length > 0) removed.push([id, gone]);
332
+ }
333
+
334
+ if (updated.length === 0 && removed.length === 0) return { kind: 'none' };
335
+ return { kind: 'properties', updated, removed };
336
+ }
337
+
338
+ // --------------------------------------------------------------------------
339
+ // Answering the protocol
340
+ // --------------------------------------------------------------------------
341
+
342
+ /** `props` filtered to `names`, or all of them when the shell asked for none. */
343
+ function filterProps(props, names) {
344
+ if (!names || names.length === 0) return props;
345
+ const out = {};
346
+ // `hasOwn`, not `in`: a shell asking for `constructor` or `toString` would
347
+ // otherwise be answered with a function, which fails to marshal and turns
348
+ // one nonsense property name into an error reply for the whole call.
349
+ for (const name of names)
350
+ if (Object.hasOwn(props, name)) out[name] = props[name];
351
+ return out;
352
+ }
353
+
354
+ /**
355
+ * `GetLayout`'s recursive `(ia{sv}av)`, as plain values — the caller wraps
356
+ * them for the wire.
357
+ *
358
+ * `depth` follows the spec: `-1` recurses all the way, `0` stops at this item,
359
+ * `n` takes n levels of children.
360
+ */
361
+ export function layoutOf(nodes, id, depth, names) {
362
+ const node = nodes.get(id);
363
+ if (!node) return null;
364
+ const children =
365
+ depth === 0
366
+ ? []
367
+ : node.childIds
368
+ .map((child) =>
369
+ layoutOf(nodes, child, depth < 0 ? -1 : depth - 1, names),
370
+ )
371
+ .filter(Boolean);
372
+ return [node.id, filterProps(node.props, names), children];
373
+ }
374
+
375
+ /** `GetGroupProperties`' `a(ia{sv})`. Unknown ids are skipped, not an error. */
376
+ export function groupProperties(nodes, ids, names) {
377
+ const wanted = ids && ids.length > 0 ? ids : [...nodes.keys()];
378
+ const out = [];
379
+ for (const id of wanted) {
380
+ const node = nodes.get(id);
381
+ if (node) out.push([id, filterProps(node.props, names)]);
382
+ }
383
+ return out;
384
+ }
package/src/debug.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ // Hand-written declarations for `react-x11/debug` (docs/debugging.md).
2
+
3
+ export type TraceSink = 'summary' | 'requests' | 'chrome';
4
+
5
+ export interface TraceStats {
6
+ /** Requests sent since the trace attached. */
7
+ requests: number;
8
+ bytesOut: number;
9
+ /** Replies received (each one is a round trip the client waited for). */
10
+ replies: number;
11
+ events: number;
12
+ errors: number;
13
+ bytesIn: number;
14
+ /** Decoded request name — e.g. 'CopyArea', 'Render.CompositeGlyphs32' —
15
+ * to how many times it was sent. */
16
+ byOpcode: Map<string, number>;
17
+ /** ntk's rounded-rect fast path (ntk >= 6.7.0): boxes emitted as cached
18
+ * corner glyphs + FillRectangles, against those that fell back to
19
+ * polygon rasterization, counted by reason. Both zero on older ntk. */
20
+ shapes: ShapeStats;
21
+ }
22
+
23
+ export interface ShapeStats {
24
+ /** Rounded boxes drawn as corner glyphs + rects. */
25
+ hits: number;
26
+ /** Bail-out reason — 'fractional', 'gradient', 'clip-mask', 'dashes',
27
+ * 'transform', 'radius-cap', 'composite-op', 'geometry', 'radii-mix',
28
+ * 'join' — to how many draws took it. */
29
+ misses: Record<string, number>;
30
+ }
31
+
32
+ export interface TraceOptions {
33
+ /** Trace one specific ntk App. Without it, the trace follows every
34
+ * connection the renderer has open or opens later. */
35
+ app?: unknown;
36
+ /** 'summary' (default): totals at stop. 'requests': a stderr line per
37
+ * request and per frame. 'chrome': Trace Event JSON written to `path`. */
38
+ sink?: TraceSink;
39
+ /** Output file for the 'chrome' sink. Default 'react-x11-trace.json'. */
40
+ path?: string;
41
+ /** Capture a JS stack per request (node-x11's seq2stack), so an async X
42
+ * error names the call that sent it. One Error capture per request. */
43
+ seq2stack?: boolean;
44
+ }
45
+
46
+ export interface TraceSession {
47
+ /** Live totals; the same object `stop()` returns. */
48
+ stats: TraceStats;
49
+ /** Detach from every connection, flush the sink, return the totals. */
50
+ stop(): TraceStats;
51
+ }
52
+
53
+ export function startTrace(options?: TraceOptions): TraceSession;
54
+
55
+ /** What `REACT_X11_TRACE=<spec>` calls at startup. Grammar:
56
+ * `summary` | `requests[+stacks]` | `chrome[+stacks]:<path>`. */
57
+ export function startEnvTrace(spec: string): TraceSession | null;
58
+
59
+ /** The one-line rounded-box tally the 'summary' sink prints, or null when
60
+ * nothing drew a rounded box. */
61
+ export function formatShapes(
62
+ shapes: ShapeStats | null | undefined,
63
+ ): string | null;