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.
- package/README.md +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- 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
|
+
}
|
package/src/dbusmenu.js
ADDED
|
@@ -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;
|