react-x11 2.6.0 → 2.7.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 +5 -3
- package/package.json +10 -3
- package/src/activate.js +12 -0
- package/src/anchor.js +6 -0
- package/src/appearance.js +351 -28
- package/src/appearancehooks.js +5 -2
- package/src/application.js +41 -0
- package/src/cocoa/app.js +358 -20
- package/src/cocoa/bezels.js +51 -1
- package/src/cocoa/context2d.js +271 -25
- package/src/cocoa/dnd.js +347 -0
- package/src/cocoa/dock.js +39 -0
- package/src/cocoa/filepanels.js +155 -0
- package/src/cocoa/fonts.js +93 -2
- package/src/cocoa/globalmenu.js +41 -33
- package/src/cocoa/notifications.js +244 -0
- package/src/cocoa/permissions.js +74 -0
- package/src/cocoa/presenter.js +190 -2
- package/src/cocoa/statusitem.js +112 -0
- package/src/cocoa/window.js +85 -4
- package/src/components/Button.js +20 -1
- package/src/components/Checkbox.js +17 -2
- package/src/components/Menu.js +108 -38
- package/src/components/Radio.js +17 -2
- package/src/components/Select.js +159 -27
- package/src/components/Switch.js +8 -1
- package/src/components/native.js +99 -0
- package/src/components/theme.js +37 -20
- package/src/desktopsettings.js +34 -2
- package/src/dnd.js +92 -3
- package/src/errors.js +6 -3
- package/src/filedialog.js +81 -16
- package/src/index.d.ts +17 -1
- package/src/index.js +17 -0
- package/src/launcher.js +170 -0
- package/src/launcherhooks.js +81 -0
- package/src/nodes.js +553 -35
- package/src/notificationhooks.js +56 -0
- package/src/notifications.js +558 -0
- package/src/palette.js +144 -8
- package/src/permissionhooks.js +89 -0
- package/src/permissions.js +196 -0
- package/src/screens.js +39 -4
- package/src/style.d.ts +10 -4
- package/src/style.js +1 -0
- package/src/styles.js +161 -15
- package/src/textselection.js +1 -4
- package/src/trayhooks.js +90 -0
- package/src/types/appearance.d.ts +24 -0
- package/src/types/components.d.ts +10 -0
- package/src/types/elements.d.ts +14 -0
- package/src/types/events.d.ts +14 -0
- package/src/types/filedialog.d.ts +18 -7
- package/src/types/launcher.d.ts +43 -0
- package/src/types/notifications.d.ts +113 -0
- package/src/types/permissions.d.ts +100 -0
- package/src/types/style.d.ts +30 -2
- package/src/types/system.d.ts +5 -3
- package/src/types/tray.d.ts +54 -0
- package/src/windowid.js +23 -0
package/src/cocoa/globalmenu.js
CHANGED
|
@@ -51,6 +51,41 @@ function keyEquivalent(shortcut) {
|
|
|
51
51
|
return { key: key.toLowerCase(), modifiers: modifiers || FLAG_COMMAND };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A dbusmenu snapshot (`snapshot()`'s `Map<id, node>`) as the bridge's menu
|
|
56
|
+
* item spec — the vocabulary `setMainMenu`, `setDockMenu` and a status
|
|
57
|
+
* item's menu all take, so the menu bar, the Dock menu and the tray are one
|
|
58
|
+
* builder. `ids` defaults to the root's children.
|
|
59
|
+
*/
|
|
60
|
+
export function menuItemsSpec(nodes, ids = nodes.get(ROOT_ID).childIds) {
|
|
61
|
+
return ids.map((id) => {
|
|
62
|
+
const node = nodes.get(id);
|
|
63
|
+
const props = node.props;
|
|
64
|
+
const out = { id };
|
|
65
|
+
if (props.type === 'separator') {
|
|
66
|
+
out.separator = true;
|
|
67
|
+
if (props.visible === false) out.hidden = true;
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
out.title = String(props.label ?? '');
|
|
71
|
+
if (props.enabled === false) out.enabled = false;
|
|
72
|
+
if (props.visible === false) out.hidden = true;
|
|
73
|
+
if (props['toggle-state'] === 1) out.checked = true;
|
|
74
|
+
// the serialisable icon pair (menuitem.js): the name is read in the
|
|
75
|
+
// platform's icon theme — SF Symbols here, freedesktop on a Linux panel
|
|
76
|
+
// — and the bytes are the literal-pixel fallback
|
|
77
|
+
if (props['icon-name']) out.iconName = props['icon-name'];
|
|
78
|
+
if (props['icon-data']) out.iconData = props['icon-data'];
|
|
79
|
+
const key = keyEquivalent(props.shortcut);
|
|
80
|
+
if (key) {
|
|
81
|
+
out.key = key.key;
|
|
82
|
+
out.modifiers = key.modifiers;
|
|
83
|
+
}
|
|
84
|
+
if (node.childIds.length) out.items = menuItemsSpec(nodes, node.childIds);
|
|
85
|
+
return out;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
54
89
|
export class CocoaGlobalMenuExport {
|
|
55
90
|
constructor(app, { getMenus, onSelect, onAboutToShow, target, onChange }) {
|
|
56
91
|
this.app = app;
|
|
@@ -93,35 +128,7 @@ export class CocoaGlobalMenuExport {
|
|
|
93
128
|
* `activate` like every real item.
|
|
94
129
|
*/
|
|
95
130
|
_spec() {
|
|
96
|
-
const
|
|
97
|
-
ids.map((id) => {
|
|
98
|
-
const node = this.nodes.get(id);
|
|
99
|
-
const props = node.props;
|
|
100
|
-
const out = { id };
|
|
101
|
-
if (props.type === 'separator') {
|
|
102
|
-
out.separator = true;
|
|
103
|
-
if (props.visible === false) out.hidden = true;
|
|
104
|
-
return out;
|
|
105
|
-
}
|
|
106
|
-
out.title = String(props.label ?? '');
|
|
107
|
-
if (props.enabled === false) out.enabled = false;
|
|
108
|
-
if (props.visible === false) out.hidden = true;
|
|
109
|
-
if (props['toggle-state'] === 1) out.checked = true;
|
|
110
|
-
// the serialisable icon pair (menuitem.js): the name is read in
|
|
111
|
-
// the platform's icon theme — SF Symbols here, freedesktop on a
|
|
112
|
-
// Linux panel — and the bytes are the literal-pixel fallback
|
|
113
|
-
if (props['icon-name']) out.iconName = props['icon-name'];
|
|
114
|
-
if (props['icon-data']) out.iconData = props['icon-data'];
|
|
115
|
-
const key = keyEquivalent(props.shortcut);
|
|
116
|
-
if (key) {
|
|
117
|
-
out.key = key.key;
|
|
118
|
-
out.modifiers = key.modifiers;
|
|
119
|
-
}
|
|
120
|
-
if (node.childIds.length) out.items = itemsOf(node.childIds);
|
|
121
|
-
return out;
|
|
122
|
-
});
|
|
123
|
-
const root = this.nodes.get(ROOT_ID);
|
|
124
|
-
const menus = itemsOf(root.childIds).map((menu) => ({
|
|
131
|
+
const menus = menuItemsSpec(this.nodes).map((menu) => ({
|
|
125
132
|
title: menu.title ?? '',
|
|
126
133
|
items: menu.items ?? [],
|
|
127
134
|
}));
|
|
@@ -137,10 +144,11 @@ export class CocoaGlobalMenuExport {
|
|
|
137
144
|
/** A backend menu-activate event landed on this export. */
|
|
138
145
|
activate(id) {
|
|
139
146
|
if (id === QUIT_ID) {
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
147
|
+
// ⌘Q is a quit request like the Dock's and a logout's: one route for
|
|
148
|
+
// all of them (`CocoaApp.requestQuit`), which is the primary window's
|
|
149
|
+
// close request — the app decides what closing means, exactly as it
|
|
150
|
+
// would for the red light
|
|
151
|
+
this.app.requestQuit();
|
|
144
152
|
return;
|
|
145
153
|
}
|
|
146
154
|
const item = this.nodes?.get(id)?.item;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Desktop notifications on the cocoa backend — `UNUserNotificationCenter`
|
|
2
|
+
// through @windowkit/appkit (>= 0.5), the top rung of src/notifications.js's
|
|
3
|
+
// ladder. `CocoaApp.notifications` is this object; its presence is the
|
|
4
|
+
// capability, and its `available()` is the second gate: the centre only
|
|
5
|
+
// delivers for a code-signed app bundle with a bundle id, and a bare `node`
|
|
6
|
+
// process reports itself unavailable so the ladder can move on to
|
|
7
|
+
// `osascript` rather than post into the void.
|
|
8
|
+
//
|
|
9
|
+
// Three things about the system shape the code:
|
|
10
|
+
//
|
|
11
|
+
// - **Authorization is the system's prompt, once per bundle.** The first
|
|
12
|
+
// post from an app the user has not answered for asks first, and a
|
|
13
|
+
// refusal is a refusal — reported, not fallen through.
|
|
14
|
+
// - **Actions are categories.** The centre knows an action set only as a
|
|
15
|
+
// registered category, and `setNotificationCategories` *replaces* the set,
|
|
16
|
+
// so every distinct action list this process has used stays registered
|
|
17
|
+
// under a name derived from its keys. A click on the banner itself is the
|
|
18
|
+
// action `default`, the freedesktop word the ladder speaks.
|
|
19
|
+
// - **The user's answer comes back as an event** on the app's callback,
|
|
20
|
+
// naming the notification by identifier — `notification-action` and
|
|
21
|
+
// `notification-dismissed`, which the app routes here.
|
|
22
|
+
|
|
23
|
+
function categoryId(actions) {
|
|
24
|
+
return `react-x11:${actions.map((a) => a.key).join(',')}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class CocoaNotifications {
|
|
28
|
+
constructor(app) {
|
|
29
|
+
this.app = app;
|
|
30
|
+
this._native = app._native;
|
|
31
|
+
this._handles = new Map(); // identifier -> handle
|
|
32
|
+
this._categories = new Map(); // id -> { id, actions }
|
|
33
|
+
this._settings = null;
|
|
34
|
+
this._authorized = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The bridge's settings, read once — `available` is the bundle question. */
|
|
38
|
+
settings() {
|
|
39
|
+
if (this._settings) return this._settings;
|
|
40
|
+
this._settings = new Promise((resolve) => {
|
|
41
|
+
try {
|
|
42
|
+
this._native.notificationSettings((s) =>
|
|
43
|
+
resolve(s ?? { available: false }),
|
|
44
|
+
);
|
|
45
|
+
} catch {
|
|
46
|
+
resolve({ available: false });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
return this._settings;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async available() {
|
|
53
|
+
const s = await this.settings();
|
|
54
|
+
return s?.available === true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ask once; the system remembers, and so does this.
|
|
59
|
+
*
|
|
60
|
+
* Three answers, not two. `granted` and `denied` are the obvious pair, and
|
|
61
|
+
* `unasked` is the one that matters: a falsy `granted` does **not** mean
|
|
62
|
+
* the user said no. A bundle Launch Services has never registered — an
|
|
63
|
+
* ad-hoc signature in a temp directory, say — gets no prompt at all, and
|
|
64
|
+
* the request comes back refused with the status still `notDetermined`.
|
|
65
|
+
* Calling that a refusal would tell the user they declined something they
|
|
66
|
+
* were never shown, and would stop a ladder that has every right to carry
|
|
67
|
+
* on: nobody has turned anything off. So the status is read again
|
|
68
|
+
* afterwards, and it is the system, not the boolean, that says which
|
|
69
|
+
* happened.
|
|
70
|
+
*
|
|
71
|
+
* @returns {Promise<'granted'|'denied'|'unasked'>}
|
|
72
|
+
*/
|
|
73
|
+
async _authorize() {
|
|
74
|
+
if (this._authorized != null) return this._authorized;
|
|
75
|
+
const s = await this.settings();
|
|
76
|
+
if (
|
|
77
|
+
s.authorizationStatus === 'authorized' ||
|
|
78
|
+
s.authorizationStatus === 'provisional'
|
|
79
|
+
) {
|
|
80
|
+
this._authorized = 'granted';
|
|
81
|
+
return this._authorized;
|
|
82
|
+
}
|
|
83
|
+
if (s.authorizationStatus === 'denied') {
|
|
84
|
+
this._authorized = 'denied';
|
|
85
|
+
return this._authorized;
|
|
86
|
+
}
|
|
87
|
+
const granted = await new Promise((resolve) => {
|
|
88
|
+
try {
|
|
89
|
+
this._native.requestNotificationAuthorization(
|
|
90
|
+
['alert', 'sound', 'badge'],
|
|
91
|
+
(ok) => resolve(Boolean(ok)),
|
|
92
|
+
);
|
|
93
|
+
} catch {
|
|
94
|
+
resolve(false);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
if (granted) {
|
|
98
|
+
this._authorized = 'granted';
|
|
99
|
+
return this._authorized;
|
|
100
|
+
}
|
|
101
|
+
// Refused — by the user, or by a system that never asked one. The prompt
|
|
102
|
+
// moves the status off `notDetermined`; nothing else does.
|
|
103
|
+
this._settings = null;
|
|
104
|
+
const after = await this.settings();
|
|
105
|
+
if (after.authorizationStatus === 'notDetermined') {
|
|
106
|
+
// Not cached: a later post, from a bundle the system has since
|
|
107
|
+
// registered, deserves the prompt this one never got.
|
|
108
|
+
return 'unasked';
|
|
109
|
+
}
|
|
110
|
+
this._authorized = 'denied';
|
|
111
|
+
return this._authorized;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_ensureCategory(actions) {
|
|
115
|
+
if (!actions?.length) return undefined;
|
|
116
|
+
const id = categoryId(actions);
|
|
117
|
+
if (!this._categories.has(id)) {
|
|
118
|
+
this._categories.set(id, {
|
|
119
|
+
id,
|
|
120
|
+
actions: actions.map((a) => ({
|
|
121
|
+
id: a.key,
|
|
122
|
+
title: a.label ?? a.key,
|
|
123
|
+
foreground: true,
|
|
124
|
+
})),
|
|
125
|
+
});
|
|
126
|
+
this._native.setNotificationCategories([...this._categories.values()]);
|
|
127
|
+
}
|
|
128
|
+
return id;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_props(options, identifier) {
|
|
132
|
+
const props = {
|
|
133
|
+
title: options.summary,
|
|
134
|
+
body: options.body ?? '',
|
|
135
|
+
sound: options.silent ? null : 'default',
|
|
136
|
+
};
|
|
137
|
+
if (identifier) props.identifier = identifier;
|
|
138
|
+
if (options.subtitle) props.subtitle = options.subtitle;
|
|
139
|
+
const categoryId = this._ensureCategory(options.actions);
|
|
140
|
+
if (categoryId) props.categoryId = categoryId;
|
|
141
|
+
if (options.userInfo) props.userInfo = options.userInfo;
|
|
142
|
+
return props;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Post, if the user has allowed it.
|
|
147
|
+
*
|
|
148
|
+
* `null` rather than a throw when the centre could not ask (see
|
|
149
|
+
* `_authorize`): the ladder reads that as "this rung cannot serve" and
|
|
150
|
+
* moves on. A real refusal still throws, because it is an answer.
|
|
151
|
+
*/
|
|
152
|
+
async post(options) {
|
|
153
|
+
const auth = await this._authorize();
|
|
154
|
+
if (auth === 'denied') {
|
|
155
|
+
const err = new Error(
|
|
156
|
+
'react-x11: notifications are not allowed for this app — the user ' +
|
|
157
|
+
'declined them in System Settings › Notifications.',
|
|
158
|
+
);
|
|
159
|
+
err.name = 'NotificationsDeniedError';
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
if (auth !== 'granted') return null;
|
|
163
|
+
const handle = new CocoaHandle(this, options);
|
|
164
|
+
await handle._post({});
|
|
165
|
+
return handle;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `notification-action` / `notification-dismissed` from the app. */
|
|
169
|
+
route(ev) {
|
|
170
|
+
const handle = this._handles.get(ev.identifier);
|
|
171
|
+
if (!handle) return;
|
|
172
|
+
if (ev.type === 'notification-action') {
|
|
173
|
+
handle._action(
|
|
174
|
+
ev.actionId === 'default' ? 'default' : String(ev.actionId),
|
|
175
|
+
);
|
|
176
|
+
} else if (ev.type === 'notification-dismissed') {
|
|
177
|
+
this._handles.delete(ev.identifier);
|
|
178
|
+
handle._closed('dismissed');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
class CocoaHandle {
|
|
184
|
+
constructor(centre, options) {
|
|
185
|
+
this.backend = 'cocoa';
|
|
186
|
+
this.id = null;
|
|
187
|
+
this._centre = centre;
|
|
188
|
+
this._options = options;
|
|
189
|
+
this._done = false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_post(patch) {
|
|
193
|
+
const centre = this._centre;
|
|
194
|
+
const native = centre._native;
|
|
195
|
+
this._options = { ...this._options, ...patch };
|
|
196
|
+
const props = centre._props(this._options, this.id);
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
const done = (error) => (error ? reject(error) : resolve(this));
|
|
199
|
+
try {
|
|
200
|
+
if (this.id) {
|
|
201
|
+
native.updateNotification(this.id, props, done);
|
|
202
|
+
} else {
|
|
203
|
+
this.id = native.postNotification(props, done);
|
|
204
|
+
centre._handles.set(this.id, this);
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
reject(err);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
update(patch = {}) {
|
|
213
|
+
if (this._done) return Promise.resolve(this);
|
|
214
|
+
return this._post(patch);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async close() {
|
|
218
|
+
if (this._done || !this.id) return;
|
|
219
|
+
this._centre._handles.delete(this.id);
|
|
220
|
+
try {
|
|
221
|
+
this._centre._native.removeNotification(this.id);
|
|
222
|
+
} finally {
|
|
223
|
+
this._closed('closed');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
_action(key) {
|
|
228
|
+
try {
|
|
229
|
+
this._options.onAction?.(key);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.error('react-x11: a notification action handler threw', err);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
_closed(reason) {
|
|
236
|
+
if (this._done) return;
|
|
237
|
+
this._done = true;
|
|
238
|
+
try {
|
|
239
|
+
this._options.onClose?.(reason);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error('react-x11: a notification close handler threw', err);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// macOS privacy authorizations (TCC) on the cocoa backend, through
|
|
2
|
+
// @windowkit/appkit (>= 0.5): the rung that answers in src/permissions.js's
|
|
3
|
+
// ladder. `CocoaApp.permissions` is this object and its presence is the
|
|
4
|
+
// capability, the rule `filePanels` and `nativeBezels` follow.
|
|
5
|
+
//
|
|
6
|
+
// The bridge is mechanism — `authorizationStatus(kind, opts?)` never
|
|
7
|
+
// prompts, `requestAuthorization(kind, opts?, cb)` raises the system's
|
|
8
|
+
// prompt where a framework offers one and answers once, asynchronously,
|
|
9
|
+
// `openPrivacySettings(kind?)` deep-links — and its vocabulary is Apple's.
|
|
10
|
+
// What is decided here is the translation into the ladder's five words,
|
|
11
|
+
// and one thing worth knowing: a bare `node` process is attributed to its
|
|
12
|
+
// responsible process (the terminal, an IDE) or to `node` itself, and a
|
|
13
|
+
// bundled app must carry the usage-description keys
|
|
14
|
+
// (`NSCameraUsageDescription` and friends) or a request never prompts.
|
|
15
|
+
|
|
16
|
+
/** Apple's four words as the ladder's. */
|
|
17
|
+
const STATUS = Object.freeze({
|
|
18
|
+
authorized: 'granted',
|
|
19
|
+
denied: 'denied',
|
|
20
|
+
restricted: 'restricted',
|
|
21
|
+
notDetermined: 'prompt',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function statusFromBridge(status) {
|
|
25
|
+
return STATUS[status] ?? 'unknown';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The bridge's options for a kind: `automation` carries its target. */
|
|
29
|
+
function bridgeOptions(kind, options = {}) {
|
|
30
|
+
return kind === 'automation' && options.target != null
|
|
31
|
+
? { target: String(options.target) }
|
|
32
|
+
: undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class CocoaPermissions {
|
|
36
|
+
constructor(native) {
|
|
37
|
+
this._native = native;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
status(kind, options) {
|
|
41
|
+
const opts = bridgeOptions(kind, options);
|
|
42
|
+
return statusFromBridge(
|
|
43
|
+
opts
|
|
44
|
+
? this._native.authorizationStatus(kind, opts)
|
|
45
|
+
: this._native.authorizationStatus(kind),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
request(kind, options) {
|
|
50
|
+
const opts = bridgeOptions(kind, options);
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const done = (granted, status) =>
|
|
53
|
+
resolve(
|
|
54
|
+
status != null
|
|
55
|
+
? statusFromBridge(status)
|
|
56
|
+
: granted
|
|
57
|
+
? 'granted'
|
|
58
|
+
: 'denied',
|
|
59
|
+
);
|
|
60
|
+
try {
|
|
61
|
+
if (opts) this._native.requestAuthorization(kind, opts, done);
|
|
62
|
+
else this._native.requestAuthorization(kind, done);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
reject(err);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
openSettings(kind) {
|
|
70
|
+
if (kind == null) this._native.openPrivacySettings();
|
|
71
|
+
else this._native.openPrivacySettings(kind);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/cocoa/presenter.js
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
damageToPaint,
|
|
33
33
|
intersectRects,
|
|
34
34
|
} from '../nodes.js';
|
|
35
|
+
import { EASING_CONTROL_POINTS, TRANSITION_CONTROL_POINTS } from '../styles.js';
|
|
35
36
|
import { CocoaContext2D } from './context2d.js';
|
|
36
37
|
|
|
37
38
|
const RASTER_PAD = 2; // antialiasing/italic overhang outside the ink bounds
|
|
@@ -324,9 +325,8 @@ const EDGE_PROPS = [
|
|
|
324
325
|
'borderEndWidth',
|
|
325
326
|
];
|
|
326
327
|
|
|
327
|
-
function stylePaintsPlain(node) {
|
|
328
|
+
function stylePaintsPlain(node, style = node.style ?? {}) {
|
|
328
329
|
if (node.kind !== 'box') return false;
|
|
329
|
-
const style = node.style ?? {};
|
|
330
330
|
if (style.backgroundImage || style.boxShadow || style.outlineWidth) {
|
|
331
331
|
return false;
|
|
332
332
|
}
|
|
@@ -339,6 +339,26 @@ function stylePaintsPlain(node) {
|
|
|
339
339
|
return uniformRadius(style.borderRadius) !== null;
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Style property → the key path on a PropBox's own layer, for the
|
|
344
|
+
* animations the presenter takes off the frame clock and hands to the
|
|
345
|
+
* render server (docs/architecture/animation.md §4.2). Only what the layer
|
|
346
|
+
* expresses as a property of itself qualifies: a colour on `<text>` is a
|
|
347
|
+
* re-raster per frame, a layout property moves the siblings, and both stay
|
|
348
|
+
* on the JS loop. `scaled` values go out in points, as `_syncPropBox`
|
|
349
|
+
* sends them.
|
|
350
|
+
*/
|
|
351
|
+
const ANIMATED_KEY_PATHS = Object.freeze({
|
|
352
|
+
backgroundColor: { keyPath: 'backgroundColor', colour: true },
|
|
353
|
+
borderColor: { keyPath: 'borderColor', colour: true },
|
|
354
|
+
borderWidth: { keyPath: 'borderWidth', scaled: true },
|
|
355
|
+
borderRadius: { keyPath: 'cornerRadius', scaled: true },
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// the ids the bridge reports an animation's end under: unique per process,
|
|
359
|
+
// looked up per app (`_animationEnds`)
|
|
360
|
+
let animationSeq = 0;
|
|
361
|
+
|
|
342
362
|
function uniformRadius(radius) {
|
|
343
363
|
if (radius === undefined) return 0;
|
|
344
364
|
if (typeof radius === 'number') return radius;
|
|
@@ -439,6 +459,10 @@ export class CocoaLayerPresenter {
|
|
|
439
459
|
this.visuals = new Map(); // node -> Visual
|
|
440
460
|
this.rasters = new Map(); // node -> RasterState
|
|
441
461
|
this.bars = new Map(); // scroller node -> Map(axis -> { layer, raster })
|
|
462
|
+
// animations taken off the frame clock: accepted and waiting for the
|
|
463
|
+
// frame that attaches them, and running in the render server
|
|
464
|
+
this.pendingAnimations = new Map(); // node -> Map(prop -> entry)
|
|
465
|
+
this.liveAnimations = new Map(); // node -> Map(id -> { prop, key, entry })
|
|
442
466
|
// Claims since the last frame — taken at the top of `frame()`, the way
|
|
443
467
|
// the X11 path takes its damage before painting, so a claim made from
|
|
444
468
|
// inside a paint lands in the next frame instead of being cleared with
|
|
@@ -548,6 +572,7 @@ export class CocoaLayerPresenter {
|
|
|
548
572
|
visual.destroy();
|
|
549
573
|
this.visuals.delete(node);
|
|
550
574
|
this._dropRaster(node);
|
|
575
|
+
this._dropAnimations(node, false);
|
|
551
576
|
const bars = this.bars.get(node);
|
|
552
577
|
if (bars) {
|
|
553
578
|
for (const entry of bars.values()) {
|
|
@@ -612,8 +637,14 @@ export class CocoaLayerPresenter {
|
|
|
612
637
|
if (visual && visual.isRaster !== wantsRaster) {
|
|
613
638
|
visual.destroy();
|
|
614
639
|
this._dropRaster(node);
|
|
640
|
+
// a layer that turns into a raster takes its animations with it; the
|
|
641
|
+
// frame clock can still run them over the bitmap
|
|
642
|
+
if (wantsRaster) this._dropAnimations(node, true);
|
|
615
643
|
visual = null;
|
|
616
644
|
}
|
|
645
|
+
if (wantsRaster && this.pendingAnimations.has(node)) {
|
|
646
|
+
this._dropAnimations(node, true);
|
|
647
|
+
}
|
|
617
648
|
if (!visual) {
|
|
618
649
|
visual = new Visual(this, node);
|
|
619
650
|
visual.isRaster = wantsRaster;
|
|
@@ -728,6 +759,163 @@ export class CocoaLayerPresenter {
|
|
|
728
759
|
])
|
|
729
760
|
: [0, 0, 0, 0],
|
|
730
761
|
});
|
|
762
|
+
// after the model value went out, inside the same transaction
|
|
763
|
+
this._applyAnimations(node, visual);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// --- animations the render server runs -----------------------------------
|
|
767
|
+
//
|
|
768
|
+
// The node model keeps deciding what is animating and when it ends
|
|
769
|
+
// (nodes.js `_retarget` / `_updateLoops`); what moves here is who
|
|
770
|
+
// interpolates. Taken means the node's style goes to its target — the
|
|
771
|
+
// layer's model value, sent by the next frame's property diff — and that
|
|
772
|
+
// frame attaches an explicit animation carrying the pixels there; no frame
|
|
773
|
+
// after it is scheduled for the property, and a loop costs no JS frames at
|
|
774
|
+
// all. Declined means the frame clock runs it exactly as before, so
|
|
775
|
+
// nothing here is load-bearing for correctness.
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Take `prop`'s animation for `node`, or decline. Decided against the
|
|
779
|
+
* *target* style: a `:hover` that adds a shadow turns the node into a
|
|
780
|
+
* raster in the same swap that starts a fade, and a raster's background
|
|
781
|
+
* is in its bitmap, not on its layer.
|
|
782
|
+
*/
|
|
783
|
+
animate(node, prop, entry) {
|
|
784
|
+
const map = ANIMATED_KEY_PATHS[prop];
|
|
785
|
+
if (!map || !stylePaintsPlain(node, node._targetStyle ?? node.style)) {
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
if (this._value(map, entry.from) == null) return false;
|
|
789
|
+
if (this._value(map, entry.to) == null) return false;
|
|
790
|
+
let pending = this.pendingAnimations.get(node);
|
|
791
|
+
if (!pending) this.pendingAnimations.set(node, (pending = new Map()));
|
|
792
|
+
pending.set(prop, entry);
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Stop what runs for `prop` on `node`: a loop the window lost sight of,
|
|
797
|
+
* a declaration that changed, a transition the clock takes back. */
|
|
798
|
+
cancel(node, prop) {
|
|
799
|
+
this.pendingAnimations.get(node)?.delete(prop);
|
|
800
|
+
this._removeLive(node, prop);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
_ends() {
|
|
804
|
+
const app = this.window.app;
|
|
805
|
+
return (app._animationEnds ??= new Map());
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** A style value as the layer takes it — a colour as components, a
|
|
809
|
+
* length in points — or null for one the layer cannot animate. */
|
|
810
|
+
_value(map, value) {
|
|
811
|
+
if (map.colour) {
|
|
812
|
+
return typeof value === 'string'
|
|
813
|
+
? (this.window.app._parseColor(value) ?? null)
|
|
814
|
+
: null;
|
|
815
|
+
}
|
|
816
|
+
return typeof value === 'number'
|
|
817
|
+
? value / (map.scaled ? this.scale : 1)
|
|
818
|
+
: null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** The frame's half: attach what `animate` accepted, after the model
|
|
822
|
+
* value went out, inside the same transaction. */
|
|
823
|
+
_applyAnimations(node, visual) {
|
|
824
|
+
const pending = this.pendingAnimations.get(node);
|
|
825
|
+
if (!pending) return;
|
|
826
|
+
this.pendingAnimations.delete(node);
|
|
827
|
+
for (const [prop, entry] of pending) {
|
|
828
|
+
const map = ANIMATED_KEY_PATHS[prop];
|
|
829
|
+
const id = `rx${++animationSeq}`;
|
|
830
|
+
const key = `${prop}:${id}`;
|
|
831
|
+
const opts = { duration: entry.duration / 1000, id };
|
|
832
|
+
if (entry.loop) {
|
|
833
|
+
// a loop replaces whatever ran for the property: its declaration
|
|
834
|
+
// changed, and a loop restarts from the top when it does
|
|
835
|
+
this._removeLive(node, prop);
|
|
836
|
+
opts.from = this._value(map, entry.from);
|
|
837
|
+
opts.to = this._value(map, entry.to);
|
|
838
|
+
opts.timing = EASING_CONTROL_POINTS[entry.easing];
|
|
839
|
+
opts.repeat = Infinity;
|
|
840
|
+
opts.autoreverse = entry.alternate;
|
|
841
|
+
} else if (map.colour) {
|
|
842
|
+
// From where the pixels are — which is what "an interrupted
|
|
843
|
+
// transition reverses from where it got to" means here. A colour
|
|
844
|
+
// cannot be additive, so the one before it is replaced.
|
|
845
|
+
this._removeLive(node, prop);
|
|
846
|
+
const shown = this.native.presentationValue?.(
|
|
847
|
+
visual.layer,
|
|
848
|
+
map.keyPath,
|
|
849
|
+
);
|
|
850
|
+
opts.from = Array.isArray(shown) ? shown : this._value(map, entry.from);
|
|
851
|
+
opts.to = this._value(map, entry.to);
|
|
852
|
+
opts.timing = TRANSITION_CONTROL_POINTS;
|
|
853
|
+
} else {
|
|
854
|
+
// Additive: a delta over the model value, (old − new) → 0, and the
|
|
855
|
+
// ones before it keep running and sum. Continuity on a retarget with
|
|
856
|
+
// nothing read back, however many are in flight.
|
|
857
|
+
opts.from = this._value(map, entry.from) - this._value(map, entry.to);
|
|
858
|
+
opts.to = 0;
|
|
859
|
+
opts.additive = true;
|
|
860
|
+
opts.timing = TRANSITION_CONTROL_POINTS;
|
|
861
|
+
}
|
|
862
|
+
this.native.addAnimation(visual.layer, map.keyPath, opts, key);
|
|
863
|
+
// looked up after the removals above, which may have pruned the map
|
|
864
|
+
let live = this.liveAnimations.get(node);
|
|
865
|
+
if (!live) this.liveAnimations.set(node, (live = new Map()));
|
|
866
|
+
live.set(id, { prop, key, entry });
|
|
867
|
+
this._ends().set(id, (ev) => this._animationEnded(node, id, ev));
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** Every animation running for `prop` on `node`, off the layer and out
|
|
872
|
+
* of the books. */
|
|
873
|
+
_removeLive(node, prop) {
|
|
874
|
+
const live = this.liveAnimations.get(node);
|
|
875
|
+
if (!live) return;
|
|
876
|
+
const visual = this.visuals.get(node);
|
|
877
|
+
for (const [id, run] of live) {
|
|
878
|
+
if (run.prop !== prop) continue;
|
|
879
|
+
live.delete(id);
|
|
880
|
+
this._ends().delete(id);
|
|
881
|
+
if (visual) this.native.removeAnimation(visual.layer, run.key);
|
|
882
|
+
}
|
|
883
|
+
if (live.size === 0) this.liveAnimations.delete(node);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** The bridge's `animation-end` for one of ours — it ran out, or CA
|
|
887
|
+
* dropped it. An older additive one ending changes nothing: the node
|
|
888
|
+
* checks the entry is still the one it holds. */
|
|
889
|
+
_animationEnded(node, id) {
|
|
890
|
+
this._ends().delete(id);
|
|
891
|
+
const live = this.liveAnimations.get(node);
|
|
892
|
+
const run = live?.get(id);
|
|
893
|
+
if (!run) return;
|
|
894
|
+
live.delete(id);
|
|
895
|
+
if (live.size === 0) this.liveAnimations.delete(node);
|
|
896
|
+
node._offloadEnded(run.prop, run.entry);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** The layer is going — the visual is destroyed, or turns into a raster —
|
|
900
|
+
* and every animation on it goes with it. What was still waiting for a
|
|
901
|
+
* frame goes back to the frame clock when the node stays (`reclaim`);
|
|
902
|
+
* what was running is over, and the model shows. */
|
|
903
|
+
_dropAnimations(node, reclaim) {
|
|
904
|
+
const pending = this.pendingAnimations.get(node);
|
|
905
|
+
if (pending) {
|
|
906
|
+
this.pendingAnimations.delete(node);
|
|
907
|
+
for (const [prop, entry] of pending) {
|
|
908
|
+
if (reclaim) node._offloadDeclined(prop, entry);
|
|
909
|
+
else node._offloadEnded(prop, entry);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const live = this.liveAnimations.get(node);
|
|
913
|
+
if (!live) return;
|
|
914
|
+
this.liveAnimations.delete(node);
|
|
915
|
+
for (const [id, run] of live) {
|
|
916
|
+
this._ends().delete(id);
|
|
917
|
+
node._offloadEnded(run.prop, run.entry);
|
|
918
|
+
}
|
|
731
919
|
}
|
|
732
920
|
|
|
733
921
|
/**
|