react-x11 2.15.3 → 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.
- package/README.md +37 -0
- package/package.json +3 -3
- package/src/Reconciler.js +85 -22
- package/src/anchor.js +60 -18
- package/src/capabilities.js +29 -4
- package/src/cocoa/app.js +15 -9
- 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.js +3 -0
- 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/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/style.d.ts +57 -0
- package/src/types/system.d.ts +104 -0
- package/src/types/tray.d.ts +14 -2
package/src/icontheme.js
ADDED
|
@@ -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
|
+
}
|
package/src/imagesource.js
CHANGED
|
@@ -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 }),
|
|
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;
|
package/src/index.js
CHANGED
|
@@ -93,6 +93,8 @@ export { useKeyboardState } from './keyboardstatehooks.js';
|
|
|
93
93
|
export { matchesShortcut } from './accelerators.js';
|
|
94
94
|
export { useAccelerator } from './acceleratorhooks.js';
|
|
95
95
|
export { useDesktopSettings } from './desktopsettingshooks.js';
|
|
96
|
+
// what the app itself remembers between launches (#592)
|
|
97
|
+
export { createSettings } from './settings.js';
|
|
96
98
|
export { loadFont, openFont } from './fonts.js';
|
|
97
99
|
export { useFont } from './fonthooks.js';
|
|
98
100
|
export { systemLocale } from './locale.js';
|
|
@@ -127,6 +129,7 @@ export {
|
|
|
127
129
|
useAnchorTracking,
|
|
128
130
|
anchorArea,
|
|
129
131
|
anchorRect,
|
|
132
|
+
anchorScreenRect,
|
|
130
133
|
centerRect,
|
|
131
134
|
screenRect,
|
|
132
135
|
} from './components/index.js';
|
package/src/node.d.ts
CHANGED
|
@@ -236,6 +236,13 @@ export interface TextStyle {
|
|
|
236
236
|
variations: Record<string, number> | undefined;
|
|
237
237
|
textRendering: TextRendering | undefined;
|
|
238
238
|
color: string;
|
|
239
|
+
/** `letterSpacing`, in device pixels like `size` — undefined unless a
|
|
240
|
+
* style or the cascade above it named one. */
|
|
241
|
+
letterSpacing: number | undefined;
|
|
242
|
+
/** The OpenType features `fontVariantNumeric` and `fontFeatureSettings`
|
|
243
|
+
* resolve to, tag → 1 on, 0 off or an alternate — undefined unless either
|
|
244
|
+
* was named. The same object for the same pair of values. */
|
|
245
|
+
features: Readonly<Record<string, number>> | undefined;
|
|
239
246
|
}
|
|
240
247
|
|
|
241
248
|
/**
|
package/src/nodes/animation.js
CHANGED
|
@@ -40,36 +40,6 @@ const gridContainerMoved = (was, now) =>
|
|
|
40
40
|
const gridItemMoved = (was, now) =>
|
|
41
41
|
GRID_ITEM_PROPS.some((prop) => was[prop] !== now[prop]);
|
|
42
42
|
|
|
43
|
-
/**
|
|
44
|
-
* The node whose bounds cover where an animating node will be next frame, or
|
|
45
|
-
* `null` when that cannot be known and the frame has to repaint everything.
|
|
46
|
-
*
|
|
47
|
-
* Three cases, and the middle one is the interesting one:
|
|
48
|
-
*
|
|
49
|
-
* - **paint-only** (a colour, an opacity): the node stays put, so its own
|
|
50
|
-
* bounds are the damage.
|
|
51
|
-
* - **a layout property on an out-of-flow node** (`position: absolute`, the
|
|
52
|
-
* arrangement a sliding thumb uses): the node moves, so its own bounds
|
|
53
|
-
* cover where it is going but not where it has been. Its *parent* covers
|
|
54
|
-
* both — an absolute child is laid out inside its parent and, being out of
|
|
55
|
-
* flow, moves nothing else when it shifts. This is what keeps a `Switch`
|
|
56
|
-
* from repainting the window on every frame of its 120ms slide.
|
|
57
|
-
* - **a layout property in flow**: a reflow can move any node in the tree,
|
|
58
|
-
* including ones that leave stale pixels outside every bound we could name
|
|
59
|
-
* here. Nothing to do but repaint in full.
|
|
60
|
-
*/
|
|
61
|
-
function damageForAnimation(node) {
|
|
62
|
-
let movesInLayout = false;
|
|
63
|
-
for (const prop of node._anim?.keys() ?? []) {
|
|
64
|
-
if (isLayoutProp(prop)) movesInLayout = true;
|
|
65
|
-
}
|
|
66
|
-
if (!movesInLayout) return node;
|
|
67
|
-
if (node.style?.position !== 'absolute') return null;
|
|
68
|
-
// A window parent bounds nothing useful — its own rect is the whole surface.
|
|
69
|
-
const parent = node.parent;
|
|
70
|
-
return parent && !parent.isWindow ? parent : null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
43
|
// Frame timestamps for transitions. Indirected so tests can drive the clock
|
|
74
44
|
// instead of sleeping through real animations.
|
|
75
45
|
export let now = () => Date.now();
|
|
@@ -129,7 +99,7 @@ export class NodeAnimation {
|
|
|
129
99
|
// such presenter, the window's frame clock runs it as it always has.
|
|
130
100
|
if (this._offload(prop, entry)) {
|
|
131
101
|
entry.offloaded = true;
|
|
132
|
-
this.root?.invalidate(false,
|
|
102
|
+
this.root?.invalidate(false, this, 'animation');
|
|
133
103
|
} else {
|
|
134
104
|
// …and one the presenter had must not keep running underneath the
|
|
135
105
|
// values the clock is about to write
|
|
@@ -342,7 +312,7 @@ export class NodeAnimation {
|
|
|
342
312
|
}
|
|
343
313
|
}
|
|
344
314
|
this.root?._animating.delete(this);
|
|
345
|
-
this.root?.invalidate(false,
|
|
315
|
+
this.root?.invalidate(false, this, 'animation');
|
|
346
316
|
}
|
|
347
317
|
|
|
348
318
|
/**
|
|
@@ -608,7 +578,7 @@ export class WindowAnimation {
|
|
|
608
578
|
// invalidate anyway, but a React prop change does not — and a transition
|
|
609
579
|
// no one schedules only runs when something else dirties the window,
|
|
610
580
|
// by which time its start is stale and it snaps to the end.
|
|
611
|
-
this.invalidate(false,
|
|
581
|
+
this.invalidate(false, node, 'animation');
|
|
612
582
|
}
|
|
613
583
|
|
|
614
584
|
/**
|
|
@@ -618,27 +588,27 @@ export class WindowAnimation {
|
|
|
618
588
|
*/
|
|
619
589
|
_advanceAnimations(now) {
|
|
620
590
|
if (this._animating.size === 0) return;
|
|
621
|
-
const claims = [];
|
|
622
591
|
for (const node of [...this._animating]) {
|
|
623
592
|
if (node.destroyed) {
|
|
624
593
|
this._animating.delete(node);
|
|
625
594
|
continue;
|
|
626
595
|
}
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
// layout
|
|
630
|
-
//
|
|
631
|
-
|
|
596
|
+
// The node where it stands, which is where it *was*: nothing has been
|
|
597
|
+
// laid out yet, so its rect is the one the last pass gave it. Where it
|
|
598
|
+
// goes is the layout pass's to claim. A tick on a layout property asks
|
|
599
|
+
// for one, and that pass claims the old and new rect of every node it
|
|
600
|
+
// moves (`_assignAbs`) — this one, and whatever this one pushed, in
|
|
601
|
+
// flow or out of it. A paint-only property moves nothing, and this
|
|
602
|
+
// claim is the whole frame.
|
|
603
|
+
//
|
|
604
|
+
// Claimed rather than left unbounded because an animation repaints
|
|
605
|
+
// every frame for as long as it runs: eight height loops in a menu-bar
|
|
606
|
+
// popover were a full-window repaint per frame at 120Hz (#603). A node
|
|
607
|
+
// that *finishes* on this tick is claimed too — it just landed on its
|
|
608
|
+
// final value, and this last frame still has to paint it there.
|
|
609
|
+
this.invalidate(false, node, 'animation');
|
|
632
610
|
if (!node._tickAnimations(now)) this._animating.delete(node);
|
|
633
611
|
}
|
|
634
612
|
this.needsPaint = true;
|
|
635
|
-
// Claim a region rather than leaving the frame unbounded: an animation is
|
|
636
|
-
// a repaint every frame for its whole duration, so this is the difference
|
|
637
|
-
// between a 120ms transition costing eight full-window repaints and eight
|
|
638
|
-
// repaints of the thing that moved. Nodes that *finished* on this tick are
|
|
639
|
-
// claimed too — one just landed on its final value and that last frame
|
|
640
|
-
// still has to paint it, which is why every transition used to end with a
|
|
641
|
-
// full-window repaint.
|
|
642
|
-
for (const claim of claims) this.invalidate(false, claim, 'animation');
|
|
643
613
|
}
|
|
644
614
|
}
|
package/src/nodes/cascade.js
CHANGED
|
@@ -190,10 +190,20 @@ export class NodeCascade {
|
|
|
190
190
|
* A detached node has no ancestors yet and so cannot see a provider two
|
|
191
191
|
* levels up; it still resolves, against the base, and `_themeChanged()` on
|
|
192
192
|
* attach re-resolves it against the real one.
|
|
193
|
+
*
|
|
194
|
+
* A window can take its palette from somewhere other than its parent: a
|
|
195
|
+
* `<ThemeProvider>` above it at the root, which is not its parent because a
|
|
196
|
+
* top-level window has none, or one directly inside the window it is
|
|
197
|
+
* nested in, which passed it on to that window. Either is `_scope`, and it
|
|
198
|
+
* comes first (nodes/scope.js).
|
|
193
199
|
*/
|
|
194
200
|
get theme() {
|
|
195
201
|
if (this._theme !== undefined) return this._theme;
|
|
196
|
-
const inherited = this.
|
|
202
|
+
const inherited = this._scope
|
|
203
|
+
? this._scope.theme
|
|
204
|
+
: this.parent
|
|
205
|
+
? this.parent.theme
|
|
206
|
+
: baseTheme();
|
|
197
207
|
const own = this.props.theme;
|
|
198
208
|
this._theme = own ? { ...inherited, ...own } : inherited;
|
|
199
209
|
return this._theme;
|
|
@@ -312,7 +322,12 @@ export class NodeCascade {
|
|
|
312
322
|
const size = (inherited.size * to) / from;
|
|
313
323
|
const cached = this._textScaled;
|
|
314
324
|
if (cached?.from !== inherited || cached.style.size !== size) {
|
|
315
|
-
|
|
325
|
+
const style = { ...inherited, size };
|
|
326
|
+
// the other length that travels, re-expressed the same way
|
|
327
|
+
if (inherited.letterSpacing) {
|
|
328
|
+
style.letterSpacing = (inherited.letterSpacing * to) / from;
|
|
329
|
+
}
|
|
330
|
+
this._textScaled = { from: inherited, style };
|
|
316
331
|
}
|
|
317
332
|
return this._textScaled.style;
|
|
318
333
|
}
|
package/src/nodes/image.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
isDirectImageSource,
|
|
12
12
|
isPathImageSource,
|
|
13
13
|
isRawImageSource,
|
|
14
|
+
isSymbolImageSource,
|
|
14
15
|
releaseImageSource,
|
|
15
16
|
toLoadablePath,
|
|
16
17
|
validateImageProps,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
// is a *load-time* SyntaxError, which would take the renderer down rather
|
|
21
22
|
// than the one feature that needs it.
|
|
22
23
|
import * as ntk from 'ntk';
|
|
24
|
+
import { symbolWeight, symbolsFor, warnOnce } from '../symbols.js';
|
|
23
25
|
import { intrinsicSize } from './layout.js';
|
|
24
26
|
import { Node } from './node.js';
|
|
25
27
|
import { DEV } from './util.js';
|
|
@@ -42,6 +44,8 @@ export class ImageNode extends Node {
|
|
|
42
44
|
this._ownedImage = null;
|
|
43
45
|
/** PictureSource/DrawableSource, when the source is server-side */
|
|
44
46
|
this._serverSource = null;
|
|
47
|
+
/** `{ symbol, … }`, when the source is a name the platform draws */
|
|
48
|
+
this._symbol = null;
|
|
45
49
|
// Resolution waits for the first layout/paint: the constructor runs in
|
|
46
50
|
// the render phase, which React may discard, and resolving here would
|
|
47
51
|
// start file reads and take cache holds nothing would ever release.
|
|
@@ -67,6 +71,25 @@ export class ImageNode extends Node {
|
|
|
67
71
|
measureContent(constraints) {
|
|
68
72
|
this._ensureSource();
|
|
69
73
|
const s = this.scale;
|
|
74
|
+
if (this._symbol) {
|
|
75
|
+
const name = this._symbol.symbol;
|
|
76
|
+
const size = symbolsFor(this.app).size(name, this._symbolOptions());
|
|
77
|
+
// A name this desktop does not have takes no room and draws nothing,
|
|
78
|
+
// which is right for an app that runs on both and wrong for a typo —
|
|
79
|
+
// development tells the two apart for it.
|
|
80
|
+
if (!size) {
|
|
81
|
+
warnOnce(
|
|
82
|
+
`react-x11: <image src={{ symbol: ${JSON.stringify(name)} }}> is ` +
|
|
83
|
+
'not a symbol this desktop has, so it takes no room and draws ' +
|
|
84
|
+
'nothing. SF Symbols are the names on macOS, and the icon ' +
|
|
85
|
+
"theme's names, like 'audio-volume-high', elsewhere.",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return intrinsicSize(
|
|
89
|
+
{ width: (size?.width ?? 0) * s, height: (size?.height ?? 0) * s },
|
|
90
|
+
constraints,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
70
93
|
return intrinsicSize(
|
|
71
94
|
{
|
|
72
95
|
width: (this.image?.width ?? 0) * s,
|
|
@@ -96,6 +119,12 @@ export class ImageNode extends Node {
|
|
|
96
119
|
return;
|
|
97
120
|
}
|
|
98
121
|
if (src == null) return;
|
|
122
|
+
if (isSymbolImageSource(src)) {
|
|
123
|
+
// nothing to load: the platform draws the name at paint, which is also
|
|
124
|
+
// when the text colour it is drawn in is known
|
|
125
|
+
this._symbol = src;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
99
128
|
if (isDirectImageSource(src)) {
|
|
100
129
|
// the caller's object — its upload cache is the dedupe, and it is
|
|
101
130
|
// never destroyed here
|
|
@@ -223,9 +252,29 @@ export class ImageNode extends Node {
|
|
|
223
252
|
this._serverSource.destroy?.();
|
|
224
253
|
this._serverSource = null;
|
|
225
254
|
}
|
|
255
|
+
this._symbol = null;
|
|
226
256
|
this.image = null;
|
|
227
257
|
}
|
|
228
258
|
|
|
259
|
+
/**
|
|
260
|
+
* How a symbol is drawn beside the text around it: at that text's size and
|
|
261
|
+
* weight unless the source says otherwise — what SF Symbols are designed
|
|
262
|
+
* for, and what lets a toolbar of them follow a theme's `fontSize` — in its
|
|
263
|
+
* colour, which is `currentColor` for an `<svg>` too. Sizes are logical.
|
|
264
|
+
*/
|
|
265
|
+
_symbolOptions() {
|
|
266
|
+
const text = this.resolvedTextStyle();
|
|
267
|
+
const src = this._symbol;
|
|
268
|
+
return {
|
|
269
|
+
pointSize: text.size / this.scale,
|
|
270
|
+
weight: symbolWeight(src.weight ?? text.weight),
|
|
271
|
+
scale: src.scale,
|
|
272
|
+
variableValue: src.variableValue,
|
|
273
|
+
displayScale: this.scale,
|
|
274
|
+
color: text.color,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
229
278
|
applyProps(newProps, oldProps) {
|
|
230
279
|
const before = oldProps ?? this.props;
|
|
231
280
|
const sourceChanged = imageSourceChanged(newProps, before);
|
|
@@ -234,12 +283,16 @@ export class ImageNode extends Node {
|
|
|
234
283
|
super.applyProps(newProps, oldProps);
|
|
235
284
|
if (!sourceChanged) return;
|
|
236
285
|
const prev = this.image;
|
|
286
|
+
const wasSymbol = this._symbol;
|
|
237
287
|
this._releaseSource();
|
|
238
288
|
this._sourceDirty = false;
|
|
239
289
|
this._resolveSource();
|
|
240
290
|
// paintChanged already claimed this node's box through super; only a
|
|
241
|
-
// new intrinsic size needs more than that
|
|
291
|
+
// new intrinsic size needs more than that — and a symbol's size is the
|
|
292
|
+
// platform's to say, so any change to one is measured again
|
|
242
293
|
if (
|
|
294
|
+
wasSymbol ||
|
|
295
|
+
this._symbol ||
|
|
243
296
|
(prev?.width ?? 0) !== (this.image?.width ?? 0) ||
|
|
244
297
|
(prev?.height ?? 0) !== (this.image?.height ?? 0)
|
|
245
298
|
) {
|
|
@@ -254,6 +307,15 @@ export class ImageNode extends Node {
|
|
|
254
307
|
|
|
255
308
|
paintContent(ctx) {
|
|
256
309
|
this._ensureSource();
|
|
310
|
+
if (this._symbol) {
|
|
311
|
+
symbolsFor(this.app).draw(
|
|
312
|
+
ctx,
|
|
313
|
+
this._symbol.symbol,
|
|
314
|
+
this.contentBox(),
|
|
315
|
+
this._symbolOptions(),
|
|
316
|
+
);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
257
319
|
if (!this.image) return;
|
|
258
320
|
const content = this.contentBox();
|
|
259
321
|
ctx.drawImage(
|
package/src/nodes/kinds.js
CHANGED
|
@@ -29,3 +29,15 @@ export const CUSTOM_SEMANTIC_NAMES = new Map();
|
|
|
29
29
|
* arrangement as above, for the other declaration a scene-drawing element
|
|
30
30
|
* makes (issue #301). */
|
|
31
31
|
export const CUSTOM_SELF_DAMAGED = new Map();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The element `<ThemeProvider>` renders to carry its palette into the node
|
|
35
|
+
* tree. Not one of `HOST_TYPES` and not documented as an element: the
|
|
36
|
+
* provider is the API. What node it becomes depends on where it is written
|
|
37
|
+
* (`createInstance`): inside a window it is a `<box>` that fills its parent —
|
|
38
|
+
* directly inside one, a `ThemeBoxNode` that also hands nested windows on to
|
|
39
|
+
* it — and at the root of the tree, above the windows, where nothing drawn
|
|
40
|
+
* may be, a `ThemeScopeNode` that draws nothing and hands the palette to the
|
|
41
|
+
* windows under it (nodes/scope.js).
|
|
42
|
+
*/
|
|
43
|
+
export const THEME_SCOPE = 'themescope';
|
package/src/nodes/layout.js
CHANGED
|
@@ -284,7 +284,11 @@ export class NodeLayout {
|
|
|
284
284
|
// cached unions all the way up with it
|
|
285
285
|
this._clearHitBounds();
|
|
286
286
|
if (layoutDiff.sink) {
|
|
287
|
-
|
|
287
|
+
// the reach `_ownPaintBounds` names, ring and shadow alike: a card
|
|
288
|
+
// pushed down by a row above it leaves its old shadow on the surface
|
|
289
|
+
// unless the claim for where it was covers that shadow too
|
|
290
|
+
const grow =
|
|
291
|
+
Math.max(this._outlineExtent(), this._shadowExtent()) + DAMAGE_SLOP;
|
|
288
292
|
const shift = layoutDiff.shift;
|
|
289
293
|
const had = old.width > 0 && old.height > 0;
|
|
290
294
|
if (shift) {
|