react-x11 2.15.3 → 2.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/package.json +3 -3
- package/src/Reconciler.js +85 -22
- package/src/acceleratorhooks.js +40 -6
- package/src/anchor.js +79 -19
- package/src/capabilities.js +29 -4
- package/src/cocoa/app.js +211 -11
- package/src/cocoa/context2d.js +23 -0
- package/src/cocoa/fonts.js +78 -0
- package/src/cocoa/presenter.js +17 -0
- package/src/cocoa/promotion.js +20 -0
- package/src/cocoa/relaunch.js +8 -3
- package/src/cocoa/symbols.js +64 -0
- package/src/cocoa/threaded.js +24 -4
- package/src/cocoa/window.js +362 -139
- package/src/components/ProgressBar.js +1 -1
- package/src/components/Slider.js +72 -39
- package/src/components/anchor.js +7 -2
- package/src/components/index.js +1 -0
- package/src/components/theme.js +32 -28
- package/src/desktopcapabilityhooks.js +29 -6
- package/src/filedialoghooks.js +3 -5
- package/src/frame/childmain.js +8 -20
- package/src/frame/env.js +2 -10
- package/src/icontheme.js +240 -0
- package/src/imagesource.js +83 -1
- package/src/index.d.ts +10 -1
- package/src/index.js +3 -0
- package/src/keysymchars.js +47 -0
- package/src/keysyms.d.ts +19 -1
- package/src/keysyms.js +107 -8
- package/src/node.d.ts +7 -0
- package/src/nodes/animation.js +17 -47
- package/src/nodes/cascade.js +17 -2
- package/src/nodes/image.js +63 -1
- package/src/nodes/kinds.js +12 -0
- package/src/nodes/layout.js +5 -1
- package/src/nodes/node.js +17 -3
- package/src/nodes/paint.js +117 -0
- package/src/nodes/scope.js +259 -0
- package/src/nodes/scrollable.js +53 -6
- package/src/nodes/text.js +2 -0
- package/src/nodes/textarea.js +1 -1
- package/src/nodes/textinput.js +1 -1
- package/src/nodes/window/anchoring.js +45 -18
- package/src/nodes/window/flush.js +6 -5
- package/src/nodes/window/popup.js +10 -0
- package/src/nodes/window/size.js +40 -2
- package/src/nodes/window/window.js +41 -14
- package/src/registry.js +2 -1
- package/src/screens.js +159 -24
- package/src/settings.js +332 -0
- package/src/statusnotifier.js +164 -17
- package/src/styles.js +212 -8
- package/src/symbols.js +200 -0
- package/src/testing/mock-app.js +10 -0
- package/src/trayhooks.js +21 -5
- package/src/types/capabilities.d.ts +13 -1
- package/src/types/components.d.ts +33 -0
- package/src/types/elements.d.ts +57 -6
- package/src/types/events.d.ts +5 -0
- package/src/types/filedialog.d.ts +3 -1
- package/src/types/style.d.ts +57 -0
- package/src/types/system.d.ts +104 -0
- package/src/types/tray.d.ts +14 -2
- package/src/wayland/xkb.js +170 -59
- package/src/windowid.js +62 -20
package/src/settings.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// What an app remembers between launches (#592): a store of JSON values in
|
|
2
|
+
// the per-user directory for this app, read once, written atomically, and
|
|
3
|
+
// coalesced so a slider's stream of changes is one write when it settles.
|
|
4
|
+
//
|
|
5
|
+
// const settings = createSettings({
|
|
6
|
+
// appId: 'com.example.Hush',
|
|
7
|
+
// defaults: { noiseType: 'brown', volume: 0.5, dark: false },
|
|
8
|
+
// });
|
|
9
|
+
// const [volume, setVolume] = settings.use('volume');
|
|
10
|
+
//
|
|
11
|
+
// Every app that remembered anything did this by hand — a directory per
|
|
12
|
+
// platform, serialize, write, debounce — and the easy version of it is wrong
|
|
13
|
+
// in three quiet ways: a crash mid-write leaves half a file, a drag writes on
|
|
14
|
+
// every event, and quitting inside the debounce loses the last change.
|
|
15
|
+
//
|
|
16
|
+
// ## Where
|
|
17
|
+
//
|
|
18
|
+
// One JSON file, `settings.json`, in the app's own directory: under
|
|
19
|
+
// `~/Library/Application Support` on macOS and `$XDG_CONFIG_HOME` (`~/.config`)
|
|
20
|
+
// elsewhere. A file rather than `NSUserDefaults` on macOS, so the format and
|
|
21
|
+
// the behaviour are one thing on every platform. The directory is named by
|
|
22
|
+
// the app id, which is a reverse-DNS name like the one `registerApplication`
|
|
23
|
+
// takes — the store does not register anything, it only needs a name no
|
|
24
|
+
// other app is using.
|
|
25
|
+
//
|
|
26
|
+
// ## When it is read and written
|
|
27
|
+
//
|
|
28
|
+
// Read **synchronously**, the first time a value is asked for, so the first
|
|
29
|
+
// render already has what was saved: an asynchronous read would render the
|
|
30
|
+
// defaults and then jump. It is a small file read once. Written
|
|
31
|
+
// **asynchronously**, a quarter second after the last change and at least
|
|
32
|
+
// once a second while changes keep coming, through a temporary file and a
|
|
33
|
+
// rename, which is atomic: the file on disk is always the old one or the new
|
|
34
|
+
// one. Whatever is still waiting is written synchronously when the process
|
|
35
|
+
// exits, and `flush()` writes it now.
|
|
36
|
+
//
|
|
37
|
+
// Two processes of the same app share the file and the last write wins;
|
|
38
|
+
// nothing here watches for another process changing it.
|
|
39
|
+
|
|
40
|
+
import * as nodeFs from 'node:fs';
|
|
41
|
+
import { homedir } from 'node:os';
|
|
42
|
+
import { dirname, join } from 'node:path';
|
|
43
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
44
|
+
|
|
45
|
+
/** A reverse-DNS app id: two or more dot-separated elements, the grammar
|
|
46
|
+
* `registerApplication` checks, which is also a safe directory name. */
|
|
47
|
+
const APP_ID_RE = /^[A-Za-z_-][A-Za-z0-9_-]*(\.[A-Za-z_-][A-Za-z0-9_-]*)+$/;
|
|
48
|
+
|
|
49
|
+
/** How long a change waits for the next one, and how long a stream of
|
|
50
|
+
* changes may put a write off. */
|
|
51
|
+
const DELAY_MS = 250;
|
|
52
|
+
const MAX_WAIT_MS = 1000;
|
|
53
|
+
|
|
54
|
+
/** The per-user directory apps keep their settings under, for a platform. */
|
|
55
|
+
export function settingsBaseDir({
|
|
56
|
+
platform = process.platform,
|
|
57
|
+
env = process.env,
|
|
58
|
+
home = homedir(),
|
|
59
|
+
} = {}) {
|
|
60
|
+
if (platform === 'darwin') {
|
|
61
|
+
return join(home, 'Library', 'Application Support');
|
|
62
|
+
}
|
|
63
|
+
if (platform === 'win32') {
|
|
64
|
+
return env.APPDATA || join(home, 'AppData', 'Roaming');
|
|
65
|
+
}
|
|
66
|
+
return env.XDG_CONFIG_HOME || join(home, '.config');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One store per file in a process, so a module evaluated twice — a hot
|
|
70
|
+
* reload — and two call sites naming the same app share their values
|
|
71
|
+
* rather than overwriting each other's writes. */
|
|
72
|
+
const stores = new Map();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The settings store for `appId`: values read from and written to its
|
|
76
|
+
* `settings.json`, with `defaults` for what was never saved.
|
|
77
|
+
*/
|
|
78
|
+
export function createSettings(options = {}) {
|
|
79
|
+
const { appId, defaults = {}, directory, fs = nodeFs } = options;
|
|
80
|
+
if (typeof appId !== 'string' || !APP_ID_RE.test(appId)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`react-x11: createSettings({ appId: ${JSON.stringify(appId)} }) — the ` +
|
|
83
|
+
"app id names the app's settings directory, so it is a reverse-DNS " +
|
|
84
|
+
'name no other app uses: two or more dot-separated elements of ' +
|
|
85
|
+
'[A-Za-z_-][A-Za-z0-9_-]*, like "com.example.myapp".',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (defaults === null || typeof defaults !== 'object') {
|
|
89
|
+
throw new Error(
|
|
90
|
+
'react-x11: createSettings({ defaults }) — expected an object of each ' +
|
|
91
|
+
"setting's value when nothing was saved, like { volume: 0.5 }.",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const path = join(
|
|
95
|
+
directory ?? join(settingsBaseDir(), appId),
|
|
96
|
+
'settings.json',
|
|
97
|
+
);
|
|
98
|
+
let store = stores.get(path);
|
|
99
|
+
if (!store) {
|
|
100
|
+
store = new SettingsStore(path, fs, options);
|
|
101
|
+
stores.set(path, store);
|
|
102
|
+
}
|
|
103
|
+
// the latest module's defaults win, as a reload's edit to them should
|
|
104
|
+
store._defaults = { ...defaults };
|
|
105
|
+
return store.api;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
class SettingsStore {
|
|
109
|
+
constructor(path, fs, { delay = DELAY_MS, maxWait = MAX_WAIT_MS }) {
|
|
110
|
+
this.path = path;
|
|
111
|
+
this.fs = fs;
|
|
112
|
+
this.delay = delay;
|
|
113
|
+
this.maxWait = maxWait;
|
|
114
|
+
this._defaults = {};
|
|
115
|
+
this._fallbacks = new Map(); // key -> the first object fallback asked with
|
|
116
|
+
this._values = null; // what was saved, read on first use
|
|
117
|
+
this._listeners = new Set();
|
|
118
|
+
this._timer = null;
|
|
119
|
+
this._firstPending = 0; // when the oldest unwritten change was made
|
|
120
|
+
// Changes are counted, and a write records the count it wrote, so a
|
|
121
|
+
// change is unsaved until a write *finished* with it in — which is what
|
|
122
|
+
// the exit path asks, since a write still in flight when the process
|
|
123
|
+
// exits never finishes.
|
|
124
|
+
this._version = 0;
|
|
125
|
+
this._savedVersion = 0;
|
|
126
|
+
this._writing = null; // the write in flight
|
|
127
|
+
this._onExit = () => this._flushSync();
|
|
128
|
+
|
|
129
|
+
const store = this;
|
|
130
|
+
this.api = {
|
|
131
|
+
path,
|
|
132
|
+
get: (key, fallback) => store.get(key, fallback),
|
|
133
|
+
set: (key, value) => store.set(key, value),
|
|
134
|
+
reset: (key) => store.reset(key),
|
|
135
|
+
flush: () => store.flush(),
|
|
136
|
+
subscribe: (listener) => store.subscribe(listener),
|
|
137
|
+
/**
|
|
138
|
+
* `[value, setValue]` for one setting, like `useState` — and every
|
|
139
|
+
* component using the same key, in any window, sees the same value.
|
|
140
|
+
*/
|
|
141
|
+
use(key, fallback) {
|
|
142
|
+
const subscribe = useCallback(
|
|
143
|
+
(listener) => store.subscribe(listener),
|
|
144
|
+
[],
|
|
145
|
+
);
|
|
146
|
+
const value = useSyncExternalStore(subscribe, () =>
|
|
147
|
+
store.get(key, fallback),
|
|
148
|
+
);
|
|
149
|
+
const setValue = useCallback(
|
|
150
|
+
(next) =>
|
|
151
|
+
store.set(
|
|
152
|
+
key,
|
|
153
|
+
typeof next === 'function'
|
|
154
|
+
? next(store.get(key, fallback))
|
|
155
|
+
: next,
|
|
156
|
+
),
|
|
157
|
+
[key, fallback],
|
|
158
|
+
);
|
|
159
|
+
return [value, setValue];
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_load() {
|
|
165
|
+
if (this._values) return this._values;
|
|
166
|
+
this._values = {};
|
|
167
|
+
let text;
|
|
168
|
+
try {
|
|
169
|
+
text = this.fs.readFileSync(this.path, 'utf8');
|
|
170
|
+
} catch (err) {
|
|
171
|
+
// never saved is the ordinary first launch; anything else is worth a line
|
|
172
|
+
if (err?.code !== 'ENOENT') {
|
|
173
|
+
console.warn(
|
|
174
|
+
`react-x11: settings at ${this.path} could not be read ` +
|
|
175
|
+
`(${err.message}); the defaults stand.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return this._values;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(text);
|
|
182
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
183
|
+
this._values = parsed;
|
|
184
|
+
return this._values;
|
|
185
|
+
}
|
|
186
|
+
throw new Error('not an object');
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.warn(
|
|
189
|
+
`react-x11: settings at ${this.path} are not a JSON object ` +
|
|
190
|
+
`(${err.message}); the defaults stand, and the next change ` +
|
|
191
|
+
'replaces the file.',
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return this._values;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
get(key, fallback) {
|
|
198
|
+
const values = this._load();
|
|
199
|
+
if (Object.hasOwn(values, key)) return values[key];
|
|
200
|
+
if (Object.hasOwn(this._defaults, key)) return this._defaults[key];
|
|
201
|
+
// An object literal is a new object every render, and a hook's snapshot
|
|
202
|
+
// that changes every time it is read never settles: the first one stands.
|
|
203
|
+
if (fallback !== null && typeof fallback === 'object') {
|
|
204
|
+
if (!this._fallbacks.has(key)) this._fallbacks.set(key, fallback);
|
|
205
|
+
return this._fallbacks.get(key);
|
|
206
|
+
}
|
|
207
|
+
return fallback;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
set(key, value) {
|
|
211
|
+
let text;
|
|
212
|
+
try {
|
|
213
|
+
text = JSON.stringify(value);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
throw new TypeError(
|
|
216
|
+
`react-x11: settings.set(${JSON.stringify(key)}) — the value has to ` +
|
|
217
|
+
`be JSON: ${err.message}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (text === undefined) {
|
|
221
|
+
throw new TypeError(
|
|
222
|
+
`react-x11: settings.set(${JSON.stringify(key)}) — ${typeof value} is ` +
|
|
223
|
+
'not a value JSON can keep; use reset() to go back to the default.',
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const values = this._load();
|
|
227
|
+
if (Object.hasOwn(values, key) && values[key] === value) return;
|
|
228
|
+
this._values = { ...values, [key]: value };
|
|
229
|
+
this._changed();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
reset(key) {
|
|
233
|
+
const values = this._load();
|
|
234
|
+
if (!Object.hasOwn(values, key)) return;
|
|
235
|
+
const rest = { ...values };
|
|
236
|
+
delete rest[key];
|
|
237
|
+
this._values = rest;
|
|
238
|
+
this._changed();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
subscribe(listener) {
|
|
242
|
+
this._listeners.add(listener);
|
|
243
|
+
return () => this._listeners.delete(listener);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
get _unsaved() {
|
|
247
|
+
return this._savedVersion !== this._version;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_changed() {
|
|
251
|
+
for (const listener of [...this._listeners]) listener();
|
|
252
|
+
if (!this._unsaved) {
|
|
253
|
+
this._firstPending = Date.now();
|
|
254
|
+
process.once('exit', this._onExit);
|
|
255
|
+
}
|
|
256
|
+
this._version++;
|
|
257
|
+
clearTimeout(this._timer);
|
|
258
|
+
const waited = Date.now() - this._firstPending;
|
|
259
|
+
const wait = Math.max(0, Math.min(this.delay, this.maxWait - waited));
|
|
260
|
+
this._timer = setTimeout(() => {
|
|
261
|
+
this._timer = null;
|
|
262
|
+
this.flush().catch((err) => {
|
|
263
|
+
console.warn(
|
|
264
|
+
`react-x11: settings at ${this.path} could not be written ` +
|
|
265
|
+
`(${err.message}).`,
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
}, wait);
|
|
269
|
+
// a pending write must not be what keeps the process alive
|
|
270
|
+
this._timer.unref?.();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Write what is waiting now. Resolves when it is on disk. */
|
|
274
|
+
async flush() {
|
|
275
|
+
clearTimeout(this._timer);
|
|
276
|
+
this._timer = null;
|
|
277
|
+
// one write at a time, and a change made during one is written after it;
|
|
278
|
+
// the one before failing is that write's to report, not this one's
|
|
279
|
+
while (this._writing) await this._writing.catch(() => {});
|
|
280
|
+
if (!this._unsaved) return;
|
|
281
|
+
const version = this._version;
|
|
282
|
+
const text = `${JSON.stringify(this._values, null, 2)}\n`;
|
|
283
|
+
this._writing = this._write(text).finally(() => {
|
|
284
|
+
this._writing = null;
|
|
285
|
+
});
|
|
286
|
+
await this._writing;
|
|
287
|
+
this._savedVersion = version;
|
|
288
|
+
// saved, unless something changed while it was written
|
|
289
|
+
if (!this._unsaved) process.removeListener('exit', this._onExit);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async _write(text) {
|
|
293
|
+
const fs = this.fs.promises;
|
|
294
|
+
const temp = `${this.path}.${process.pid}.tmp`;
|
|
295
|
+
await fs.mkdir(dirname(this.path), { recursive: true });
|
|
296
|
+
const handle = await fs.open(temp, 'w');
|
|
297
|
+
try {
|
|
298
|
+
await handle.writeFile(text);
|
|
299
|
+
await handle.sync();
|
|
300
|
+
} finally {
|
|
301
|
+
await handle.close();
|
|
302
|
+
}
|
|
303
|
+
await fs.rename(temp, this.path);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The exit path, where nothing asynchronous runs any more. */
|
|
307
|
+
_flushSync() {
|
|
308
|
+
if (!this._unsaved) return;
|
|
309
|
+
this._savedVersion = this._version;
|
|
310
|
+
try {
|
|
311
|
+
const temp = `${this.path}.${process.pid}.tmp`;
|
|
312
|
+
this.fs.mkdirSync(dirname(this.path), { recursive: true });
|
|
313
|
+
this.fs.writeFileSync(temp, `${JSON.stringify(this._values, null, 2)}\n`);
|
|
314
|
+
this.fs.renameSync(temp, this.path);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
console.warn(
|
|
317
|
+
`react-x11: settings at ${this.path} could not be written on exit ` +
|
|
318
|
+
`(${err.message}).`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Forget every store — for tests that create stores over temporary
|
|
325
|
+
* directories and want the next `createSettings` to read afresh. */
|
|
326
|
+
export function resetSettingsForTests() {
|
|
327
|
+
for (const store of stores.values()) {
|
|
328
|
+
clearTimeout(store._timer);
|
|
329
|
+
process.removeListener('exit', store._onExit);
|
|
330
|
+
}
|
|
331
|
+
stores.clear();
|
|
332
|
+
}
|
package/src/statusnotifier.js
CHANGED
|
@@ -37,14 +37,55 @@
|
|
|
37
37
|
//
|
|
38
38
|
// The protocol carries far less about a click than AppKit does. There is no
|
|
39
39
|
// click count, no modifier state, and no item rectangle — `Activate(x, y)`
|
|
40
|
-
// gives
|
|
40
|
+
// gives a position and nothing else. Those fields are reported as
|
|
41
41
|
// `0`/`false` rather than guessed at, and `docs/desktop.md` says so, because a
|
|
42
42
|
// tray menu that only opens on shift-click is an app built on a field this
|
|
43
43
|
// rung cannot fill. The menu is the portable interaction; `onClick` is the
|
|
44
44
|
// one that degrades.
|
|
45
|
+
//
|
|
46
|
+
// ## The position has no unit
|
|
47
|
+
//
|
|
48
|
+
// The spec says "screen coordinates" and stops, which on a scaled display is
|
|
49
|
+
// two different numbers — and the hosts split between them:
|
|
50
|
+
//
|
|
51
|
+
// - **X root coordinates, device pixels.** Plasma since 5.27, on purpose:
|
|
52
|
+
// its xembed-sni-proxy synthesises X clicks from them. GNOME's
|
|
53
|
+
// AppIndicator extension passes its stage coordinates through, which are
|
|
54
|
+
// device pixels on X11 and in Wayland's physical layout, the default up
|
|
55
|
+
// to GNOME 49.
|
|
56
|
+
// - **The host's own logical pixels.** xfce4-panel, Budgie and LXQt pass
|
|
57
|
+
// their toolkit's root position through, and Cinnamon sends the icon's
|
|
58
|
+
// corner over its UI scale. GNOME's stage is in logical pixels from 50,
|
|
59
|
+
// and before it on Fedora and Debian 13, which switch the layout on
|
|
60
|
+
// downstream. So did Plasma before 5.27.
|
|
61
|
+
// - **Not a screen position.** MATE sends the item's corner in its applet's
|
|
62
|
+
// own window, and snixembed's `Activate` is always `(0, 0)`.
|
|
63
|
+
//
|
|
64
|
+
// Dividing by the scale is right for the first group and halves every click
|
|
65
|
+
// in the second; passing the numbers through is the opposite. So a click is
|
|
66
|
+
// read both ways and the screen picks (`clickReadings`, `clickPoint`):
|
|
67
|
+
//
|
|
68
|
+
// 1. **A reading no monitor holds is not one.** A tray is on the screen,
|
|
69
|
+
// and a device-pixel host's item at the right or the bottom of it —
|
|
70
|
+
// where panels put the tray — is off the screen when read as logical
|
|
71
|
+
// pixels. The common device-pixel click costs nothing more.
|
|
72
|
+
// 2. **Then the pointer.** A host that sends a position sends the
|
|
73
|
+
// pointer's or its icon's, so one `QueryPointer` names the reading the
|
|
74
|
+
// click was at.
|
|
75
|
+
// 3. **Otherwise the numbers as sent.** The pointer is somewhere else
|
|
76
|
+
// because the click was a key, or the connection is XWayland's, which
|
|
77
|
+
// is told nothing of a pointer over a Wayland panel. As sent is what the
|
|
78
|
+
// second group needs, and the first group's usual trays were settled at
|
|
79
|
+
// step 1.
|
|
80
|
+
//
|
|
81
|
+
// What that leaves wrong is a device-pixel host under XWayland whose tray
|
|
82
|
+
// still lands on a monitor read as logical pixels: in the top-left quarter
|
|
83
|
+
// of the screen, or on a desk where it falls on another monitor.
|
|
45
84
|
|
|
46
85
|
import { loadTransport, sessionBus } from './bus.js';
|
|
47
86
|
import { DbusMenuExport } from './dbusmenuexport.js';
|
|
87
|
+
import { scaleOf } from './scale.js';
|
|
88
|
+
import { screensSnapshot } from './screens.js';
|
|
48
89
|
|
|
49
90
|
export const WATCHER_NAME = 'org.kde.StatusNotifierWatcher';
|
|
50
91
|
export const WATCHER_PATH = '/StatusNotifierWatcher';
|
|
@@ -91,6 +132,43 @@ export function toPixmapArray(image) {
|
|
|
91
132
|
return [[width, height, out]];
|
|
92
133
|
}
|
|
93
134
|
|
|
135
|
+
/**
|
|
136
|
+
* How far from the pointer a click's point may be and still be the click, in
|
|
137
|
+
* logical pixels. A host that sends the pointer is within a pixel or two of
|
|
138
|
+
* it, and Cinnamon, which sends its icon's corner, within the icon. A point
|
|
139
|
+
* further off is taken as sent — which for Cinnamon, a logical-pixel host,
|
|
140
|
+
* is right anyway.
|
|
141
|
+
*/
|
|
142
|
+
const CLICK_REACH = 64;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A host's `(x, y)` read both ways — as X root coordinates, and as the host's
|
|
146
|
+
* own logical pixels — keeping the readings some monitor holds. Each is the
|
|
147
|
+
* point on the screen it names, in device pixels, and what it means in
|
|
148
|
+
* logical ones. See "The position has no unit" in the header.
|
|
149
|
+
*
|
|
150
|
+
* `screens` are device-pixel rects, as `screensSnapshot(app).screens` holds
|
|
151
|
+
* them; with none known both readings stand, and with neither on a monitor
|
|
152
|
+
* the host sent something that is not a position.
|
|
153
|
+
*/
|
|
154
|
+
function clickReadings(x, y, scale, screens = []) {
|
|
155
|
+
const readings = [
|
|
156
|
+
{ device: { x, y }, logical: { x: x / scale, y: y / scale } },
|
|
157
|
+
{ device: { x: x * scale, y: y * scale }, logical: { x, y } },
|
|
158
|
+
];
|
|
159
|
+
if (!screens.length) return readings;
|
|
160
|
+
// Edges count: a host that sends an icon's far corner can land on one.
|
|
161
|
+
return readings.filter(({ device: p }) =>
|
|
162
|
+
screens.some(
|
|
163
|
+
(m) =>
|
|
164
|
+
p.x >= m.x &&
|
|
165
|
+
p.y >= m.y &&
|
|
166
|
+
p.x <= m.x + m.width &&
|
|
167
|
+
p.y <= m.y + m.height,
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
94
172
|
/** `visible: false` is `Passive`, which is how the spec spells "hidden". */
|
|
95
173
|
function statusOf(options) {
|
|
96
174
|
if (options?.visible === false) return 'Passive';
|
|
@@ -129,8 +207,13 @@ function iconOf(icon, decode) {
|
|
|
129
207
|
* in quick succession cannot put two registrations in flight.
|
|
130
208
|
*/
|
|
131
209
|
export class StatusNotifierItem {
|
|
132
|
-
constructor({ getOptions, appId, decodeIcon, onError, slot } = {}) {
|
|
210
|
+
constructor({ getOptions, app, appId, decodeIcon, onError, slot } = {}) {
|
|
133
211
|
this.getOptions = getOptions ?? (() => null);
|
|
212
|
+
// The display the tray is on — not for the protocol, which is D-Bus
|
|
213
|
+
// only, but for a click's position: its scale, its monitors and its
|
|
214
|
+
// pointer are what put the host's numbers into logical pixels. Null is
|
|
215
|
+
// no display to ask, and a click's numbers are then passed as sent.
|
|
216
|
+
this.app = app ?? null;
|
|
134
217
|
this.appId = appId ?? 'react-x11';
|
|
135
218
|
this.decodeIcon = decodeIcon ?? (() => null);
|
|
136
219
|
this.onError = onError ?? (() => {});
|
|
@@ -453,27 +536,91 @@ export class StatusNotifierItem {
|
|
|
453
536
|
if (this.menu) this.menu.update(next.menu ?? []);
|
|
454
537
|
}
|
|
455
538
|
|
|
539
|
+
// ------------------------------------------------------------------ click
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* A click's `(x, y)` in logical screen pixels — the unit a `<popup>`'s
|
|
543
|
+
* `x`/`y` and `anchor={{ rect }}` take. Read both ways and settled by the
|
|
544
|
+
* monitors, then the pointer, then as sent: the header has why.
|
|
545
|
+
*/
|
|
546
|
+
async clickPoint(x, y) {
|
|
547
|
+
const scale = scaleOf(this.app);
|
|
548
|
+
if (scale === 1) return { x, y };
|
|
549
|
+
const readings = clickReadings(
|
|
550
|
+
x,
|
|
551
|
+
y,
|
|
552
|
+
scale,
|
|
553
|
+
screensSnapshot(this.app).screens,
|
|
554
|
+
);
|
|
555
|
+
if (readings.length === 1) return readings[0].logical;
|
|
556
|
+
const pointer = readings.length ? await this.queryPointer() : null;
|
|
557
|
+
if (!pointer) return { x, y };
|
|
558
|
+
let near = null;
|
|
559
|
+
let nearest = CLICK_REACH * scale;
|
|
560
|
+
for (const reading of readings) {
|
|
561
|
+
const d = Math.hypot(
|
|
562
|
+
reading.device.x - pointer.x,
|
|
563
|
+
reading.device.y - pointer.y,
|
|
564
|
+
);
|
|
565
|
+
if (d <= nearest) {
|
|
566
|
+
near = reading;
|
|
567
|
+
nearest = d;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return near?.logical ?? { x, y };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Where the pointer is on the X screen, in device pixels, or null where
|
|
575
|
+
* nothing can say: a backend with no X server behind it, a failed request,
|
|
576
|
+
* a pointer on another screen.
|
|
577
|
+
*/
|
|
578
|
+
queryPointer() {
|
|
579
|
+
const X = this.app?.X;
|
|
580
|
+
const root = X?.display?.screen?.[0]?.root;
|
|
581
|
+
if (typeof X?.QueryPointer !== 'function' || root == null) {
|
|
582
|
+
return Promise.resolve(null);
|
|
583
|
+
}
|
|
584
|
+
return new Promise((resolve) => {
|
|
585
|
+
try {
|
|
586
|
+
X.QueryPointer(root, (err, reply) =>
|
|
587
|
+
resolve(
|
|
588
|
+
err || !reply?.sameScreen
|
|
589
|
+
? null
|
|
590
|
+
: { x: reply.rootX, y: reply.rootY },
|
|
591
|
+
),
|
|
592
|
+
);
|
|
593
|
+
} catch {
|
|
594
|
+
resolve(null);
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
456
599
|
// --------------------------------------------------------------- protocol
|
|
457
600
|
|
|
458
601
|
defineItem(dbus) {
|
|
459
602
|
const opts = () => this.options;
|
|
460
603
|
const icon = () => iconOf(opts().icon, this.decodeIcon);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
604
|
+
// No click count, no modifiers, no item rect: the protocol has none of
|
|
605
|
+
// them. Reported as zero rather than invented — see the header. The
|
|
606
|
+
// position it does have is put into logical pixels first, and the call is
|
|
607
|
+
// answered once the app has had the click, so a throw in `onClick` still
|
|
608
|
+
// reaches the host as the error it was.
|
|
609
|
+
const click = (button) => (args) =>
|
|
610
|
+
this.clickPoint(args?.x ?? 0, args?.y ?? 0).then(({ x, y }) => {
|
|
611
|
+
opts().onClick?.({
|
|
612
|
+
button,
|
|
613
|
+
x,
|
|
614
|
+
y,
|
|
615
|
+
width: 0,
|
|
616
|
+
height: 0,
|
|
617
|
+
clickCount: 1,
|
|
618
|
+
shift: false,
|
|
619
|
+
control: false,
|
|
620
|
+
option: false,
|
|
621
|
+
command: false,
|
|
622
|
+
});
|
|
475
623
|
});
|
|
476
|
-
};
|
|
477
624
|
|
|
478
625
|
return dbus.defineInterface({
|
|
479
626
|
name: ITEM_IFACE,
|