react-x11 2.15.2 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +37 -0
  2. package/package.json +4 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/anchor.js +60 -18
  5. package/src/application.js +25 -1
  6. package/src/capabilities.js +349 -0
  7. package/src/cocoa/app.js +28 -9
  8. package/src/cocoa/context2d.js +139 -6
  9. package/src/cocoa/fonts.js +78 -0
  10. package/src/cocoa/presenter.js +17 -0
  11. package/src/cocoa/promotion.js +20 -0
  12. package/src/cocoa/relaunch.js +8 -3
  13. package/src/cocoa/symbols.js +64 -0
  14. package/src/cocoa/threaded.js +24 -4
  15. package/src/cocoa/window.js +362 -139
  16. package/src/components/ProgressBar.js +1 -1
  17. package/src/components/Slider.js +72 -39
  18. package/src/components/anchor.js +7 -2
  19. package/src/components/index.js +1 -0
  20. package/src/components/theme.js +32 -28
  21. package/src/dbusmenuexport.js +243 -0
  22. package/src/desktopcapabilityhooks.js +160 -0
  23. package/src/filedialoghooks.js +3 -5
  24. package/src/frame/childmain.js +8 -20
  25. package/src/frame/env.js +2 -10
  26. package/src/globalmenu.js +3 -205
  27. package/src/icontheme.js +240 -0
  28. package/src/imagesource.js +98 -3
  29. package/src/index.d.ts +1 -0
  30. package/src/index.js +11 -2
  31. package/src/launcher.js +235 -32
  32. package/src/launcherhooks.js +47 -28
  33. package/src/node.d.ts +7 -0
  34. package/src/nodes/animation.js +17 -47
  35. package/src/nodes/cascade.js +17 -2
  36. package/src/nodes/image.js +65 -2
  37. package/src/nodes/kinds.js +12 -0
  38. package/src/nodes/layout.js +5 -1
  39. package/src/nodes/node.js +17 -3
  40. package/src/nodes/paint.js +117 -0
  41. package/src/nodes/scope.js +259 -0
  42. package/src/nodes/scrollable.js +53 -6
  43. package/src/nodes/text.js +2 -0
  44. package/src/nodes/textarea.js +1 -1
  45. package/src/nodes/textinput.js +1 -1
  46. package/src/nodes/window/anchoring.js +45 -18
  47. package/src/nodes/window/flush.js +6 -5
  48. package/src/nodes/window/popup.js +10 -0
  49. package/src/nodes/window/size.js +40 -2
  50. package/src/nodes/window/window.js +41 -14
  51. package/src/registry.js +2 -1
  52. package/src/settings.js +332 -0
  53. package/src/statusnotifier.js +752 -0
  54. package/src/styles.js +212 -8
  55. package/src/symbols.js +200 -0
  56. package/src/testing/mock-app.js +10 -0
  57. package/src/trayhooks.js +193 -29
  58. package/src/types/capabilities.d.ts +139 -0
  59. package/src/types/components.d.ts +33 -0
  60. package/src/types/elements.d.ts +57 -6
  61. package/src/types/launcher.d.ts +50 -4
  62. package/src/types/style.d.ts +57 -0
  63. package/src/types/system.d.ts +104 -0
  64. package/src/types/tray.d.ts +64 -6
@@ -0,0 +1,240 @@
1
+ // The freedesktop icon theme: an icon found by name the way the Icon Theme
2
+ // Specification finds it, for a symbol drawn inside a window on Linux (#591).
3
+ //
4
+ // A theme is a directory of the same name under any of the base directories
5
+ // — `~/.icons`, `$XDG_DATA_HOME/icons`, each `$XDG_DATA_DIRS/icons` — with an
6
+ // `index.theme` saying which of its subdirectories hold which sizes and which
7
+ // themes it inherits from. A lookup tries the user's theme, then everything
8
+ // it inherits, then `hicolor`, the theme every other one falls back on, and
9
+ // last the loose files in `/usr/share/pixmaps`. Inside a theme it takes the
10
+ // first directory whose size matches, and failing that the closest one.
11
+ //
12
+ // The directories are listed rather than probed file by file: one `readdir`
13
+ // per directory, once, answers every name looked up there afterwards, where
14
+ // a `stat` per name per directory per theme is thousands of calls for a
15
+ // toolbar. Both run synchronously, since a layout pass cannot wait — the
16
+ // first lookup in a theme pays for the listing, and later ones are a `Map`.
17
+
18
+ import * as nodeFs from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { join } from 'node:path';
21
+
22
+ /** The formats a lookup answers with, in the specification's order. XPM is
23
+ * left out: nothing here decodes it. */
24
+ const EXTENSIONS = ['png', 'svg'];
25
+
26
+ /** Every base directory the specification searches, in its order. */
27
+ export function iconBaseDirs(env = process.env, home = homedir()) {
28
+ const dataHome = env.XDG_DATA_HOME || join(home, '.local', 'share');
29
+ const dataDirs = (env.XDG_DATA_DIRS || '/usr/local/share:/usr/share')
30
+ .split(':')
31
+ .filter(Boolean);
32
+ return [
33
+ join(home, '.icons'),
34
+ join(dataHome, 'icons'),
35
+ ...dataDirs.map((dir) => join(dir, 'icons')),
36
+ ];
37
+ }
38
+
39
+ /** Where loose icons with no theme live, searched last. */
40
+ export const PIXMAP_DIRS = ['/usr/share/pixmaps'];
41
+
42
+ /**
43
+ * `index.theme`'s keys, section by section — the desktop-entry format, which
44
+ * is an INI file with `#` comments. Values stay strings.
45
+ */
46
+ export function parseIndexTheme(text) {
47
+ const sections = new Map();
48
+ let current = null;
49
+ for (const raw of String(text).split(/\r?\n/)) {
50
+ const line = raw.trim();
51
+ if (!line || line.startsWith('#')) continue;
52
+ const header = /^\[(.+)\]$/.exec(line);
53
+ if (header) {
54
+ current = new Map();
55
+ sections.set(header[1], current);
56
+ continue;
57
+ }
58
+ const eq = line.indexOf('=');
59
+ if (current && eq > 0) {
60
+ current.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
61
+ }
62
+ }
63
+ return sections;
64
+ }
65
+
66
+ const list = (value) =>
67
+ (value ?? '')
68
+ .split(',')
69
+ .map((s) => s.trim())
70
+ .filter(Boolean);
71
+
72
+ const int = (value, fallback) => {
73
+ const n = Number.parseInt(value, 10);
74
+ return Number.isFinite(n) ? n : fallback;
75
+ };
76
+
77
+ /** One subdirectory's size rules, with the specification's defaults. */
78
+ function directoryRules(keys) {
79
+ const size = int(keys?.get('Size'), 0);
80
+ return {
81
+ size,
82
+ scale: int(keys?.get('Scale'), 1),
83
+ type: keys?.get('Type') ?? 'Threshold',
84
+ minSize: int(keys?.get('MinSize'), size),
85
+ maxSize: int(keys?.get('MaxSize'), size),
86
+ threshold: int(keys?.get('Threshold'), 2),
87
+ };
88
+ }
89
+
90
+ /** The specification's DirectoryMatchesSize. */
91
+ function matchesSize(dir, size, scale) {
92
+ if (dir.scale !== scale) return false;
93
+ if (dir.type === 'Fixed') return dir.size === size;
94
+ if (dir.type === 'Scalable')
95
+ return dir.minSize <= size && size <= dir.maxSize;
96
+ return dir.size - dir.threshold <= size && size <= dir.size + dir.threshold;
97
+ }
98
+
99
+ /** The specification's DirectorySizeDistance, with its Threshold branch's
100
+ * typos read as what they mean: the threshold's bounds, times the scale. */
101
+ function sizeDistance(dir, size, scale) {
102
+ const want = size * scale;
103
+ if (dir.type === 'Fixed') return Math.abs(dir.size * dir.scale - want);
104
+ const low = dir.type === 'Scalable' ? dir.minSize : dir.size - dir.threshold;
105
+ const high = dir.type === 'Scalable' ? dir.maxSize : dir.size + dir.threshold;
106
+ if (want < low * dir.scale) return low * dir.scale - want;
107
+ if (want > high * dir.scale) return want - high * dir.scale;
108
+ return 0;
109
+ }
110
+
111
+ /**
112
+ * Icon lookup in one theme and everything under it. `find(name, size,
113
+ * scale)` answers an absolute path, or null.
114
+ */
115
+ export class IconTheme {
116
+ constructor({
117
+ theme = 'hicolor',
118
+ baseDirs = iconBaseDirs(),
119
+ pixmapDirs = PIXMAP_DIRS,
120
+ fs = nodeFs,
121
+ } = {}) {
122
+ this.theme = theme;
123
+ this.baseDirs = baseDirs;
124
+ this.pixmapDirs = pixmapDirs;
125
+ this.fs = fs;
126
+ this._themes = new Map(); // name -> { dirs, inherits } | null
127
+ this._listings = new Map(); // absolute dir -> Set of file names | null
128
+ this._found = new Map(); // name|size|scale -> path | null
129
+ }
130
+
131
+ find(name, size, scale = 1) {
132
+ const key = `${name}\u0000${size}\u0000${scale}`;
133
+ if (this._found.has(key)) return this._found.get(key);
134
+ const path =
135
+ this._inTheme(name, size, scale, this.theme, new Set()) ??
136
+ (this.theme === 'hicolor'
137
+ ? null
138
+ : this._inTheme(name, size, scale, 'hicolor', new Set())) ??
139
+ this._loose(name);
140
+ this._found.set(key, path);
141
+ return path;
142
+ }
143
+
144
+ /** FindIconHelper: this theme, then the ones it inherits, depth first. */
145
+ _inTheme(name, size, scale, theme, seen) {
146
+ if (seen.has(theme)) return null;
147
+ seen.add(theme);
148
+ const index = this._index(theme);
149
+ if (!index) return null;
150
+ const found = this._lookup(name, size, scale, theme, index);
151
+ if (found) return found;
152
+ for (const parent of index.inherits) {
153
+ const inherited = this._inTheme(name, size, scale, parent, seen);
154
+ if (inherited) return inherited;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /** LookupIcon: a directory of the right size, else the closest one. */
160
+ _lookup(name, size, scale, theme, index) {
161
+ let closest = null;
162
+ let distance = Infinity;
163
+ for (const dir of index.dirs) {
164
+ const fits = matchesSize(dir.rules, size, scale);
165
+ const d = fits ? 0 : sizeDistance(dir.rules, size, scale);
166
+ if (!fits && d >= distance) continue;
167
+ for (const base of this.baseDirs) {
168
+ const at = join(base, theme, dir.path);
169
+ const file = this._fileIn(at, name);
170
+ if (!file) continue;
171
+ if (fits) return file;
172
+ closest = file;
173
+ distance = d;
174
+ break;
175
+ }
176
+ }
177
+ return closest;
178
+ }
179
+
180
+ /** The loose files: an icon with no theme at all. */
181
+ _loose(name) {
182
+ for (const dir of this.pixmapDirs) {
183
+ const file = this._fileIn(dir, name);
184
+ if (file) return file;
185
+ }
186
+ return null;
187
+ }
188
+
189
+ _fileIn(dir, name) {
190
+ const names = this._listing(dir);
191
+ if (!names) return null;
192
+ for (const ext of EXTENSIONS) {
193
+ const file = `${name}.${ext}`;
194
+ if (names.has(file)) return join(dir, file);
195
+ }
196
+ return null;
197
+ }
198
+
199
+ _listing(dir) {
200
+ if (this._listings.has(dir)) return this._listings.get(dir);
201
+ let names = null;
202
+ try {
203
+ names = new Set(this.fs.readdirSync(dir));
204
+ } catch {
205
+ // not there, which is most directories in most base directories
206
+ }
207
+ this._listings.set(dir, names);
208
+ return names;
209
+ }
210
+
211
+ /** A theme's `index.theme`, from the first base directory that has one. */
212
+ _index(theme) {
213
+ if (this._themes.has(theme)) return this._themes.get(theme);
214
+ let index = null;
215
+ for (const base of this.baseDirs) {
216
+ let text;
217
+ try {
218
+ text = this.fs.readFileSync(join(base, theme, 'index.theme'), 'utf8');
219
+ } catch {
220
+ continue;
221
+ }
222
+ const sections = parseIndexTheme(text);
223
+ const head = sections.get('Icon Theme');
224
+ const names = [
225
+ ...list(head?.get('Directories')),
226
+ ...list(head?.get('ScaledDirectories')),
227
+ ];
228
+ index = {
229
+ inherits: list(head?.get('Inherits')),
230
+ dirs: [...new Set(names)].map((path) => ({
231
+ path,
232
+ rules: directoryRules(sections.get(path)),
233
+ })),
234
+ };
235
+ break;
236
+ }
237
+ this._themes.set(theme, index);
238
+ return index;
239
+ }
240
+ }
@@ -44,6 +44,73 @@ export function isPathImageSource(src) {
44
44
  export const toLoadablePath = (src) =>
45
45
  typeof src === 'string' || src instanceof URL ? src : new URL(src.href);
46
46
 
47
+ /**
48
+ * A symbol by name: `{ symbol, weight?, scale?, variableValue? }` — the
49
+ * platform's own icons, drawn in the text colour (#591, src/symbols.js). Not
50
+ * pixels at all, so none of the decoding or caching below applies: the node
51
+ * asks the platform to draw the name at paint.
52
+ */
53
+ export function isSymbolImageSource(src) {
54
+ return (
55
+ src != null &&
56
+ typeof src === 'object' &&
57
+ !(src instanceof Uint8Array) &&
58
+ 'symbol' in src
59
+ );
60
+ }
61
+
62
+ const SYMBOL_SCALES = new Set(['small', 'medium', 'large']);
63
+
64
+ function validateSymbolSource(src) {
65
+ const at = `react-x11: <image src={{ symbol: ${JSON.stringify(src.symbol)} }}>`;
66
+ if (typeof src.symbol !== 'string' || src.symbol === '') {
67
+ throw new Error(
68
+ `${at} needs a name: an SF Symbol on macOS, like 'speaker.wave.3.fill', ` +
69
+ "or an icon theme's name on Linux, like 'audio-volume-high'.",
70
+ );
71
+ }
72
+ const { weight, scale, variableValue } = src;
73
+ if (
74
+ weight !== undefined &&
75
+ !(
76
+ weight === 'normal' ||
77
+ weight === 'bold' ||
78
+ (typeof weight === 'number' && weight >= 1 && weight <= 1000)
79
+ )
80
+ ) {
81
+ throw new Error(
82
+ `${at} has weight ${JSON.stringify(weight)}, expected what fontWeight ` +
83
+ "takes — 'normal', 'bold' or a number from 1 to 1000.",
84
+ );
85
+ }
86
+ if (scale !== undefined && !SYMBOL_SCALES.has(scale)) {
87
+ throw new Error(
88
+ `${at} has scale ${JSON.stringify(scale)}, expected 'small', 'medium' ` +
89
+ "or 'large'.",
90
+ );
91
+ }
92
+ if (
93
+ variableValue !== undefined &&
94
+ !(
95
+ typeof variableValue === 'number' &&
96
+ variableValue >= 0 &&
97
+ variableValue <= 1
98
+ )
99
+ ) {
100
+ throw new Error(
101
+ `${at} has variableValue ${JSON.stringify(variableValue)}, expected a ` +
102
+ 'number from 0 to 1.',
103
+ );
104
+ }
105
+ }
106
+
107
+ /** Two symbol sources naming the same drawing, however fresh the objects. */
108
+ const sameSymbol = (a, b) =>
109
+ a.symbol === b.symbol &&
110
+ a.weight === b.weight &&
111
+ a.scale === b.scale &&
112
+ a.variableValue === b.variableValue;
113
+
47
114
  /** Raw straight-RGBA pixels: `{ width, height, data }` — the shape
48
115
  * `getImageData` hands back. (A `Buffer` of encoded bytes is a `Uint8Array`
49
116
  * subclass, so the two byte forms are one check elsewhere.) */
@@ -54,6 +121,7 @@ export function isRawImageSource(src) {
54
121
  !(src instanceof Uint8Array) &&
55
122
  !isPathImageSource(src) &&
56
123
  !isDirectImageSource(src) &&
124
+ !isSymbolImageSource(src) &&
57
125
  'data' in src
58
126
  );
59
127
  }
@@ -70,7 +138,8 @@ const describe = (value) =>
70
138
  /** Stated once, so every error lists the same set of accepted forms. */
71
139
  const SRC_FORMS =
72
140
  'a file path or file URL (PNG/JPEG), encoded PNG/JPEG bytes (Buffer or ' +
73
- 'Uint8Array), raw RGBA ({ width, height, data }), or an ntk Image/Surface';
141
+ 'Uint8Array), raw RGBA ({ width, height, data }), an ntk Image/Surface, ' +
142
+ "or a symbol by name ({ symbol: 'speaker.wave.3.fill' })";
74
143
 
75
144
  function validateServerSource(kind, desc) {
76
145
  const shape =
@@ -163,6 +232,16 @@ export function validateImageProps(props) {
163
232
  if (props.drawable != null) validateServerSource('drawable', props.drawable);
164
233
  const src = props.src;
165
234
  if (src == null) return;
235
+ if (isSymbolImageSource(src)) {
236
+ if (props.cacheKey != null) {
237
+ throw new Error(
238
+ 'react-x11: <image cacheKey> names decoded pixels, and a symbol is ' +
239
+ 'drawn by name, not decoded — there is nothing to cache. Drop the ' +
240
+ 'cacheKey.',
241
+ );
242
+ }
243
+ return validateSymbolSource(src);
244
+ }
166
245
  if (isPathImageSource(src)) return;
167
246
  if (src instanceof Uint8Array) return;
168
247
  if (isDirectImageSource(src)) return;
@@ -207,6 +286,9 @@ export function imageSourceChanged(next, prev) {
207
286
  if (next.cacheKey !== prev.cacheKey) return true;
208
287
  if (next.src === prev.src) return false;
209
288
  if ((next.src == null) !== (prev.src == null)) return true;
289
+ if (isSymbolImageSource(next.src) && isSymbolImageSource(prev.src)) {
290
+ return !sameSymbol(next.src, prev.src);
291
+ }
210
292
  if (isDirectImageSource(next.src) || isDirectImageSource(prev.src))
211
293
  return true;
212
294
  return next.cacheKey == null;
@@ -250,7 +332,7 @@ export function acquireImageSource(app, key, load) {
250
332
  entry.promise = null;
251
333
  // every holder unmounted while it decoded — free, don't adopt
252
334
  if (entry.released) {
253
- image?.destroy();
335
+ freeImage(app, image);
254
336
  return null;
255
337
  }
256
338
  entry.image = image;
@@ -269,10 +351,23 @@ export function releaseImageSource(app, entry) {
269
351
  if (--entry.refs > 0) return;
270
352
  sourceCaches.get(app)?.delete(entry.key);
271
353
  entry.released = true;
272
- entry.image?.destroy();
354
+ freeImage(app, entry.image);
273
355
  entry.image = null;
274
356
  }
275
357
 
358
+ /**
359
+ * Free an `Image` this module or a node owns, on every backend it may have
360
+ * been drawn on: ntk's `destroy()` frees the pixmaps it uploaded per X
361
+ * connection, and knows nothing of an upload a backend keeps for itself —
362
+ * the Cocoa app's CG bitmap — which that app's `releaseImage` seam frees.
363
+ * An X app has no such seam, so there it is `destroy()` alone.
364
+ */
365
+ export function freeImage(app, image) {
366
+ if (!image) return;
367
+ image.destroy();
368
+ app?.releaseImage?.(image);
369
+ }
370
+
276
371
  // --- server-side sources ----------------------------------------------------
277
372
 
278
373
  /** RENDER's depth-implied standard formats — the ones a drawable can be
package/src/index.d.ts CHANGED
@@ -25,6 +25,7 @@ export * from './types/screencolor.js';
25
25
  export * from './types/appearance.js';
26
26
  export * from './types/fonts.js';
27
27
  export * from './types/system.js';
28
+ export * from './types/capabilities.js';
28
29
  export * from './types/launcher.js';
29
30
  export * from './types/tray.js';
30
31
  export * from './types/permissions.js';
package/src/index.js CHANGED
@@ -14,9 +14,15 @@ export {
14
14
  registerApplication,
15
15
  } from './application.js';
16
16
  export { useAppActivate, useAppOpen } from './apphooks.js';
17
- export { setBadge } from './launcher.js';
18
- export { useBadge, useDockMenu } from './launcherhooks.js';
17
+ export { setBadge, setProgress, setQuicklist, setUrgent } from './launcher.js';
18
+ export { useBadge, useDockMenu, useProgress } from './launcherhooks.js';
19
19
  export { useTray } from './trayhooks.js';
20
+ export {
21
+ CAPABILITIES,
22
+ NO_CAPABILITY,
23
+ desktopCapability,
24
+ } from './capabilities.js';
25
+ export { useDesktopCapability } from './desktopcapabilityhooks.js';
20
26
  export {
21
27
  NoPermissionServiceError,
22
28
  openPrivacySettings,
@@ -87,6 +93,8 @@ export { useKeyboardState } from './keyboardstatehooks.js';
87
93
  export { matchesShortcut } from './accelerators.js';
88
94
  export { useAccelerator } from './acceleratorhooks.js';
89
95
  export { useDesktopSettings } from './desktopsettingshooks.js';
96
+ // what the app itself remembers between launches (#592)
97
+ export { createSettings } from './settings.js';
90
98
  export { loadFont, openFont } from './fonts.js';
91
99
  export { useFont } from './fonthooks.js';
92
100
  export { systemLocale } from './locale.js';
@@ -121,6 +129,7 @@ export {
121
129
  useAnchorTracking,
122
130
  anchorArea,
123
131
  anchorRect,
132
+ anchorScreenRect,
124
133
  centerRect,
125
134
  screenRect,
126
135
  } from './components/index.js';