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/symbols.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Symbols by name: the platform's own icons, drawn in the text colour inside
|
|
2
|
+
// a window (#591) — the names `useTray`'s `icon` and a menu item's `iconName`
|
|
3
|
+
// already take, and nothing that draws in a window did.
|
|
4
|
+
//
|
|
5
|
+
// Two providers, one per icon system, behind the same two questions, how
|
|
6
|
+
// big and draw it here:
|
|
7
|
+
//
|
|
8
|
+
// - **SF Symbols** on the Cocoa backend, which brings its own
|
|
9
|
+
// (`app.symbols`, src/cocoa/symbols.js): a symbol is a template, drawn in
|
|
10
|
+
// the fill colour at the weight and point size of the text beside it.
|
|
11
|
+
// - **The freedesktop icon theme** everywhere else (src/icontheme.js): the
|
|
12
|
+
// user's theme, looked up at the size the text calls for. A `-symbolic` icon
|
|
13
|
+
// is preferred and drawn the same way, its shape in the text colour; an icon
|
|
14
|
+
// that is only drawn in its own colours is shown as it is.
|
|
15
|
+
//
|
|
16
|
+
// A name one system does not have simply is not there, and takes no room —
|
|
17
|
+
// the same bargain a tray icon's name makes — so an app that runs on both
|
|
18
|
+
// picks its names by platform, as it does for the tray.
|
|
19
|
+
|
|
20
|
+
import { cssColorStraight, decodeImage, SvgView } from 'ntk';
|
|
21
|
+
import * as nodeFs from 'node:fs';
|
|
22
|
+
|
|
23
|
+
import { IconTheme } from './icontheme.js';
|
|
24
|
+
import { Surface } from './ntk.js';
|
|
25
|
+
import { xsettings } from './xsettings.js';
|
|
26
|
+
|
|
27
|
+
/** A `fontWeight` as the number both icon systems configure with. */
|
|
28
|
+
export function symbolWeight(weight) {
|
|
29
|
+
if (typeof weight === 'number') return weight;
|
|
30
|
+
if (weight === 'bold') return 700;
|
|
31
|
+
return 400;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const warned = new Set();
|
|
35
|
+
/** A development warning, once per message. */
|
|
36
|
+
export function warnOnce(message) {
|
|
37
|
+
if (process.env.NODE_ENV === 'production' || warned.has(message)) return;
|
|
38
|
+
warned.add(message);
|
|
39
|
+
console.warn(message);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- the freedesktop icon theme -----------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The size a theme is asked for beside text of `pointSize`: 16 for 14px text,
|
|
46
|
+
* which is the pairing every toolkit's menus and toolbars are drawn at, and in
|
|
47
|
+
* proportion from there. An icon theme's icons are square.
|
|
48
|
+
*/
|
|
49
|
+
export const iconSizeFor = (pointSize) =>
|
|
50
|
+
Math.max(1, Math.round((pointSize * 8) / 7));
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The user's icon theme on this connection: what the settings daemon says
|
|
54
|
+
* (`Net/IconThemeName`, which GNOME and Xfce write), and failing that
|
|
55
|
+
* Adwaita, which `hicolor` then backs up. An explicit `theme` from the test
|
|
56
|
+
* seam wins.
|
|
57
|
+
*/
|
|
58
|
+
export function iconThemeName(app) {
|
|
59
|
+
const named = xsettings(app)?.get?.('Net/IconThemeName');
|
|
60
|
+
return typeof named === 'string' && named ? named : 'Adwaita';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
class FreedesktopSymbols {
|
|
64
|
+
constructor(app, { theme, baseDirs, pixmapDirs, fs = nodeFs } = {}) {
|
|
65
|
+
this.app = app;
|
|
66
|
+
this.fs = fs;
|
|
67
|
+
this.theme = new IconTheme({
|
|
68
|
+
theme: theme ?? iconThemeName(app),
|
|
69
|
+
baseDirs,
|
|
70
|
+
pixmapDirs,
|
|
71
|
+
fs,
|
|
72
|
+
});
|
|
73
|
+
this.documents = new Map(); // path -> SvgView | Image | null
|
|
74
|
+
// path|size|colour -> Surface, least recently drawn first
|
|
75
|
+
this.rasters = new Map();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `{ path, symbolic }` for a name at a logical size, or null. A symbolic
|
|
79
|
+
* variant is preferred: it is the one that takes the text colour. */
|
|
80
|
+
_resolve(name, pointSize, scale) {
|
|
81
|
+
const size = iconSizeFor(pointSize);
|
|
82
|
+
const whole = Math.max(1, Math.round(scale));
|
|
83
|
+
const symbolic = name.endsWith('-symbolic');
|
|
84
|
+
for (const candidate of symbolic ? [name] : [`${name}-symbolic`, name]) {
|
|
85
|
+
const path = this.theme.find(candidate, size, whole);
|
|
86
|
+
if (path) return { path, symbolic: candidate.endsWith('-symbolic') };
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
size(name, options) {
|
|
92
|
+
if (!this._resolve(name, options.pointSize, options.displayScale)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const side = iconSizeFor(options.pointSize);
|
|
96
|
+
return { width: side, height: side };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
draw(ctx, name, rect, options) {
|
|
100
|
+
const found = this._resolve(name, options.pointSize, options.displayScale);
|
|
101
|
+
if (!found) return false;
|
|
102
|
+
// square, centred in the box, on whole device pixels
|
|
103
|
+
const side = Math.max(1, Math.floor(Math.min(rect.width, rect.height)));
|
|
104
|
+
const x = Math.round(rect.x + (rect.width - side) / 2);
|
|
105
|
+
const y = Math.round(rect.y + (rect.height - side) / 2);
|
|
106
|
+
const raster = this._raster(found, side, options.color);
|
|
107
|
+
if (!raster) return false;
|
|
108
|
+
ctx.drawImage(raster, x, y, side, side);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The icon drawn at `side` device pixels — in `color` for a symbolic one —
|
|
113
|
+
* kept for the next paint. */
|
|
114
|
+
_raster(found, side, color) {
|
|
115
|
+
const ink = found.symbolic
|
|
116
|
+
? (cssColorStraight(color) ?? [0, 0, 0, 1])
|
|
117
|
+
: null;
|
|
118
|
+
const key = `${found.path}\u0000${side}\u0000${ink?.join(',') ?? ''}`;
|
|
119
|
+
const kept = this.rasters.get(key);
|
|
120
|
+
if (kept) {
|
|
121
|
+
this.rasters.delete(key);
|
|
122
|
+
this.rasters.set(key, kept);
|
|
123
|
+
return kept;
|
|
124
|
+
}
|
|
125
|
+
const document = this._document(found.path);
|
|
126
|
+
if (!document) return null;
|
|
127
|
+
let surface;
|
|
128
|
+
try {
|
|
129
|
+
surface = new Surface(this.app, { width: side, height: side });
|
|
130
|
+
} catch {
|
|
131
|
+
return null; // no offscreen surfaces here: the headless mock
|
|
132
|
+
}
|
|
133
|
+
surface.render((sctx) => {
|
|
134
|
+
sctx.clearRect(0, 0, side, side);
|
|
135
|
+
if (document instanceof SvgView) document.draw(sctx, 0, 0, side, side);
|
|
136
|
+
else sctx.drawImage(document, 0, 0, side, side);
|
|
137
|
+
if (ink) {
|
|
138
|
+
// the shape's coverage, in the text colour: what AppKit does with a
|
|
139
|
+
// template, and what GTK does with a symbolic icon
|
|
140
|
+
sctx.globalCompositeOperation = 'source-in';
|
|
141
|
+
sctx.fillStyle = `rgba(${Math.round(ink[0] * 255)}, ${Math.round(ink[1] * 255)}, ${Math.round(ink[2] * 255)}, ${ink[3]})`;
|
|
142
|
+
sctx.fillRect(0, 0, side, side);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
this.rasters.set(key, surface);
|
|
146
|
+
if (this.rasters.size > 64) {
|
|
147
|
+
const [oldest, evicted] = this.rasters.entries().next().value;
|
|
148
|
+
this.rasters.delete(oldest);
|
|
149
|
+
evicted.destroy?.();
|
|
150
|
+
}
|
|
151
|
+
return surface;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_document(path) {
|
|
155
|
+
if (this.documents.has(path)) return this.documents.get(path);
|
|
156
|
+
let document = null;
|
|
157
|
+
try {
|
|
158
|
+
const bytes = this.fs.readFileSync(path);
|
|
159
|
+
if (path.endsWith('.svg')) {
|
|
160
|
+
document = new SvgView(null);
|
|
161
|
+
document.setSvg(bytes.toString('utf8'));
|
|
162
|
+
} else {
|
|
163
|
+
document = decodeImage(bytes);
|
|
164
|
+
}
|
|
165
|
+
} catch (err) {
|
|
166
|
+
warnOnce(`react-x11: the icon ${path} did not load: ${err.message}`);
|
|
167
|
+
}
|
|
168
|
+
this.documents.set(path, document);
|
|
169
|
+
return document;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
destroy() {
|
|
173
|
+
for (const surface of this.rasters.values()) surface.destroy?.();
|
|
174
|
+
this.rasters.clear();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- per app --------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
const providers = new WeakMap();
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The symbol provider for a connection: the one the app brings, which is the
|
|
184
|
+
* Cocoa app's SF Symbols, or the freedesktop icon theme, made on first use.
|
|
185
|
+
*/
|
|
186
|
+
export function symbolsFor(app) {
|
|
187
|
+
let provider = providers.get(app);
|
|
188
|
+
if (!provider) {
|
|
189
|
+
provider = app?.symbols ?? new FreedesktopSymbols(app);
|
|
190
|
+
providers.set(app, provider);
|
|
191
|
+
}
|
|
192
|
+
return provider;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Look icons up in a theme of the test's own, at base directories it names. */
|
|
196
|
+
export function setIconThemeForTests(app, options) {
|
|
197
|
+
providers.get(app)?.destroy?.();
|
|
198
|
+
if (options == null) providers.delete(app);
|
|
199
|
+
else providers.set(app, new FreedesktopSymbols(app, options));
|
|
200
|
+
}
|
package/src/testing/mock-app.js
CHANGED
|
@@ -288,6 +288,16 @@ export function createMockApp() {
|
|
|
288
288
|
wnd.grabbed = false;
|
|
289
289
|
wnd.calls.push(['ungrabPointer']);
|
|
290
290
|
},
|
|
291
|
+
// …and a popup that takes the keyboard holds this one
|
|
292
|
+
grabKeyboard(options, cb) {
|
|
293
|
+
wnd.keyboardGrabbed = true;
|
|
294
|
+
wnd.calls.push(['grabKeyboard']);
|
|
295
|
+
cb?.(null, 0);
|
|
296
|
+
},
|
|
297
|
+
ungrabKeyboard() {
|
|
298
|
+
wnd.keyboardGrabbed = false;
|
|
299
|
+
wnd.calls.push(['ungrabKeyboard']);
|
|
300
|
+
},
|
|
291
301
|
// ntk >= 7.5.0. `xi2: 'auto'` creates a window on core events and
|
|
292
302
|
// calls this the first time it is scrolled, so the call is the
|
|
293
303
|
// observable half of the upgrade — `wnd.xi2Selected` is what a test
|
package/src/trayhooks.js
CHANGED
|
@@ -74,9 +74,10 @@ import { StatusNotifierItem, allocateItemSlot } from './statusnotifier.js';
|
|
|
74
74
|
*
|
|
75
75
|
* With `menu`, a click opens it — the same `items` vocabulary `MenuBar` and
|
|
76
76
|
* `useDockMenu` take, an item's `onSelect` firing when picked. Without one,
|
|
77
|
-
* `onClick` is called with the button and
|
|
78
|
-
*
|
|
79
|
-
* while mounted; the item is
|
|
77
|
+
* `onClick` is called with the button and where the click was, in logical
|
|
78
|
+
* screen pixels — with the item's rect, where the backend knows it. `null`
|
|
79
|
+
* means no item. Every field follows its value while mounted; the item is
|
|
80
|
+
* removed on unmount.
|
|
80
81
|
*
|
|
81
82
|
* `available` is whether this backend has a tray at all, and it **settles**:
|
|
82
83
|
* false on the first frame, true once a tray has been found. Branch on it for
|
|
@@ -89,6 +90,8 @@ export function useTray(options) {
|
|
|
89
90
|
const itemRef = useRef(null);
|
|
90
91
|
const [rect] = useState(null);
|
|
91
92
|
const [remote, setRemote] = useState(false);
|
|
93
|
+
// whether the freedesktop registration has answered, either way
|
|
94
|
+
const [answered, setAnswered] = useState(false);
|
|
92
95
|
const [error, setError] = useState(null);
|
|
93
96
|
// The feature vocabulary for whichever rung answered. Probed once per
|
|
94
97
|
// backend rather than per render — it describes the mechanism, not the
|
|
@@ -166,6 +169,10 @@ export function useTray(options) {
|
|
|
166
169
|
const appId = currentRegistration()?.appId ?? 'react-x11';
|
|
167
170
|
const item = new StatusNotifierItem({
|
|
168
171
|
getOptions: () => live.current,
|
|
172
|
+
// The display a click's position is read against: the host sends it in
|
|
173
|
+
// a unit of its own choosing — see "The position has no unit" in
|
|
174
|
+
// statusnotifier.js.
|
|
175
|
+
app,
|
|
169
176
|
appId,
|
|
170
177
|
slot: slotRef.current,
|
|
171
178
|
decodeIcon: decodeIconBytes,
|
|
@@ -180,16 +187,19 @@ export function useTray(options) {
|
|
|
180
187
|
sniRef.current = item;
|
|
181
188
|
previous.current = { ...live.current };
|
|
182
189
|
void item.start().then((ok) => {
|
|
183
|
-
if (
|
|
190
|
+
if (cancelled) return;
|
|
191
|
+
setRemote(ok);
|
|
192
|
+
setAnswered(true);
|
|
184
193
|
});
|
|
185
194
|
return () => {
|
|
186
195
|
cancelled = true;
|
|
187
196
|
sniRef.current = null;
|
|
188
197
|
setRemote(false);
|
|
198
|
+
setAnswered(false);
|
|
189
199
|
setError(null);
|
|
190
200
|
void item.stop();
|
|
191
201
|
};
|
|
192
|
-
}, [native, options == null]);
|
|
202
|
+
}, [app, native, options == null]);
|
|
193
203
|
|
|
194
204
|
// The item reads its fields through `getOptions`, so a render only has to
|
|
195
205
|
// say *which* of them moved — see `StatusNotifierItem.update`.
|
|
@@ -215,6 +225,12 @@ export function useTray(options) {
|
|
|
215
225
|
const backend = native ? 'cocoa' : remote ? caps.backend : null;
|
|
216
226
|
return {
|
|
217
227
|
available: native || remote,
|
|
228
|
+
// Whether `available` is an answer yet: at once on the Cocoa rung and for
|
|
229
|
+
// no item at all, and on the freedesktop one once the host has taken or
|
|
230
|
+
// refused the registration — so an app whose whole UI is its tray can
|
|
231
|
+
// render nothing until then, rather than a fallback window that flashes
|
|
232
|
+
// up and away on every start.
|
|
233
|
+
settled: native || !options || answered,
|
|
218
234
|
backend,
|
|
219
235
|
features: native || remote ? caps.features : NO_CAPABILITY.features,
|
|
220
236
|
error,
|
|
@@ -124,4 +124,16 @@ export declare function desktopCapability<N extends DesktopCapabilityName>(
|
|
|
124
124
|
*/
|
|
125
125
|
export declare function useDesktopCapability<N extends DesktopCapabilityName>(
|
|
126
126
|
name: N,
|
|
127
|
-
):
|
|
127
|
+
): DesktopCapabilityState<N>;
|
|
128
|
+
|
|
129
|
+
/** {@link useDesktopCapability}'s answer, and whether it is one yet. */
|
|
130
|
+
export type DesktopCapabilityState<N extends DesktopCapabilityName> =
|
|
131
|
+
DesktopCapabilityFor<N> & {
|
|
132
|
+
/**
|
|
133
|
+
* False until the first probe answers, and true from then on, re-probes
|
|
134
|
+
* included — so an app can hold its fallback back rather than flash it.
|
|
135
|
+
* True on the first frame where nothing had to be asked: the tray and
|
|
136
|
+
* the Dock tile on macOS.
|
|
137
|
+
*/
|
|
138
|
+
settled: boolean;
|
|
139
|
+
};
|
|
@@ -418,6 +418,17 @@ export interface SliderProps
|
|
|
418
418
|
disabled?: boolean;
|
|
419
419
|
height?: number;
|
|
420
420
|
style?: StyleProp;
|
|
421
|
+
/**
|
|
422
|
+
* The thumb's style, over its default: a 16px circle on `surface` with a
|
|
423
|
+
* ring. Its `width` and `height`, as numbers, are what the drag's travel
|
|
424
|
+
* and the control's height are measured with.
|
|
425
|
+
*/
|
|
426
|
+
thumbStyle?: StyleProp;
|
|
427
|
+
/** The track's style, over `height` and `track`. Its `height` is the
|
|
428
|
+
* fill's too. */
|
|
429
|
+
trackStyle?: StyleProp;
|
|
430
|
+
/** The filled part of the track, up to the value, over `accent`. */
|
|
431
|
+
fillStyle?: StyleProp;
|
|
421
432
|
}
|
|
422
433
|
export const Slider: ComponentType<SliderProps>;
|
|
423
434
|
|
|
@@ -761,6 +772,28 @@ export function anchorRect(
|
|
|
761
772
|
options?: AnchorOptions,
|
|
762
773
|
): AnchorRect | null;
|
|
763
774
|
|
|
775
|
+
/**
|
|
776
|
+
* The same placement against a rect **on the screen** rather than a node —
|
|
777
|
+
* the item a tray click reports, a point where the pointer was — in logical
|
|
778
|
+
* screen pixels on both sides. With no node to ask, `scale` is the
|
|
779
|
+
* display's (default 1) and `direction` decides `'start'` and `'end'`; the
|
|
780
|
+
* popup is kept on the monitor the rect is on. `null` for a rect with no
|
|
781
|
+
* `x`/`y`.
|
|
782
|
+
*/
|
|
783
|
+
export function anchorScreenRect(
|
|
784
|
+
app: unknown,
|
|
785
|
+
rect: ScreenAnchorRect,
|
|
786
|
+
options?: Omit<AnchorOptions, 'at' | 'alignTo'> & { scale?: number },
|
|
787
|
+
): AnchorRect | null;
|
|
788
|
+
|
|
789
|
+
/** A rect on the screen, in logical pixels; `{x, y}` alone is a point. */
|
|
790
|
+
export interface ScreenAnchorRect {
|
|
791
|
+
x: number;
|
|
792
|
+
y: number;
|
|
793
|
+
width?: number;
|
|
794
|
+
height?: number;
|
|
795
|
+
}
|
|
796
|
+
|
|
764
797
|
/** Centre a popup of this size on the node's screen. */
|
|
765
798
|
export function centerRect(
|
|
766
799
|
node: DrawnNode,
|
package/src/types/elements.d.ts
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Ref, RefObject, ReactNode, Key } from 'react';
|
|
8
|
-
import type { Color, Cursor, StyleProp } from './style.js';
|
|
8
|
+
import type { Color, Cursor, FontWeight, StyleProp } from './style.js';
|
|
9
9
|
import type {
|
|
10
10
|
DrawnNode,
|
|
11
11
|
NtkWindow,
|
|
12
12
|
ScrollableNode,
|
|
13
13
|
TextInputNode,
|
|
14
14
|
} from './nodes.js';
|
|
15
|
-
import type { AnchorOptions } from './components.js';
|
|
15
|
+
import type { AnchorOptions, ScreenAnchorRect } from './components.js';
|
|
16
16
|
import type {
|
|
17
17
|
ChangeEvent,
|
|
18
18
|
SelectionChangeEvent,
|
|
@@ -580,6 +580,16 @@ export interface PopupProps extends WindowProps {
|
|
|
580
580
|
* this client at all. Needs ntk >= 3.7.0.
|
|
581
581
|
*/
|
|
582
582
|
grab?: boolean;
|
|
583
|
+
/**
|
|
584
|
+
* Take the keyboard while the popup is up: its keys come to it whatever
|
|
585
|
+
* holds the focus, which is what a popover a tray click opened needs — a
|
|
586
|
+
* menu-bar app has no window of its own for keys to arrive at. A keyboard
|
|
587
|
+
* grab on X, taken and dropped with the map like `grab`. On macOS a window
|
|
588
|
+
* AppKit can make key, shown activating the app, as `NSPopover`'s own
|
|
589
|
+
* window is — decided when the popup is created, so it does not change on
|
|
590
|
+
* a mounted one. A grabbing popup on Wayland has the keyboard already.
|
|
591
|
+
*/
|
|
592
|
+
grabKeyboard?: boolean;
|
|
583
593
|
/**
|
|
584
594
|
* `true` (the default) keeps the window manager out entirely, which is
|
|
585
595
|
* what makes a menu a menu — no frame, no repositioning, no taskbar entry.
|
|
@@ -597,7 +607,9 @@ export interface PopupProps extends WindowProps {
|
|
|
597
607
|
/**
|
|
598
608
|
* Hang this popup off a node — `anchorRect`'s options plus the node to
|
|
599
609
|
* measure, and the popup works out its own position from them, ignoring
|
|
600
|
-
* `x`/`y`.
|
|
610
|
+
* `x`/`y`. Or off a rect on the screen with no node behind it, `rect`
|
|
611
|
+
* instead of `to`: the item a tray click reports, placed against with the
|
|
612
|
+
* same flip and clamp.
|
|
601
613
|
*
|
|
602
614
|
* What this does that computing a rect in the application cannot: a popup
|
|
603
615
|
* with an `'auto'` size only knows how big it is *inside* `realize()`,
|
|
@@ -622,12 +634,26 @@ export interface PopupProps extends WindowProps {
|
|
|
622
634
|
* that renders them) or the node itself. */
|
|
623
635
|
export type AnchorTarget = DrawnNode | RefObject<DrawnNode | null> | null;
|
|
624
636
|
|
|
625
|
-
|
|
637
|
+
/** Where a popup hangs: a node, or a rect on the screen. */
|
|
638
|
+
export type PopupAnchor = NodeAnchor | ScreenAnchor;
|
|
639
|
+
|
|
640
|
+
export interface NodeAnchor extends Omit<AnchorOptions, 'alignTo'> {
|
|
626
641
|
/** The node this popup hangs off. */
|
|
627
642
|
to: AnchorTarget;
|
|
628
643
|
/** Takes the alignment axis from another node — see
|
|
629
644
|
* {@link AnchorOptions.alignTo}. */
|
|
630
645
|
alignTo?: AnchorTarget;
|
|
646
|
+
rect?: never;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export interface ScreenAnchor extends Omit<AnchorOptions, 'alignTo' | 'at'> {
|
|
650
|
+
/**
|
|
651
|
+
* A rect on the screen, in logical pixels — what `useTray`'s `onClick`
|
|
652
|
+
* reports where the platform knows the item's frame. `{x, y}` alone is a
|
|
653
|
+
* point. Nothing moves it but a new one, and it is never out of view.
|
|
654
|
+
*/
|
|
655
|
+
rect: ScreenAnchorRect;
|
|
656
|
+
to?: never;
|
|
631
657
|
}
|
|
632
658
|
|
|
633
659
|
// --- drawn elements --------------------------------------------------------
|
|
@@ -758,10 +784,35 @@ export interface FileUrl {
|
|
|
758
784
|
|
|
759
785
|
/**
|
|
760
786
|
* What `src` accepts: a file path or file URL (PNG/JPEG, decoded in JS),
|
|
761
|
-
* encoded PNG/JPEG bytes, raw RGBA pixels,
|
|
787
|
+
* encoded PNG/JPEG bytes, raw RGBA pixels, an ntk `Image`/`Surface`, or a
|
|
788
|
+
* symbol by name.
|
|
762
789
|
*/
|
|
763
790
|
export type ImageSource =
|
|
764
|
-
|
|
791
|
+
| string
|
|
792
|
+
| FileUrl
|
|
793
|
+
| Uint8Array
|
|
794
|
+
| RawImageSource
|
|
795
|
+
| DirectImageSource
|
|
796
|
+
| SymbolImageSource;
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* The platform's own icon by name, drawn in the text colour: an SF Symbol on
|
|
800
|
+
* macOS (`'speaker.wave.3.fill'`), and elsewhere an icon from the user's
|
|
801
|
+
* freedesktop icon theme (`'audio-volume-high'`), its `-symbolic` variant
|
|
802
|
+
* preferred. Sized like the text around it — the size its `fontSize` calls
|
|
803
|
+
* for — unless the element is styled a size of its own. A name this desktop
|
|
804
|
+
* does not have takes no room and draws nothing.
|
|
805
|
+
*/
|
|
806
|
+
export interface SymbolImageSource {
|
|
807
|
+
symbol: string;
|
|
808
|
+
/** Default: the weight of the text around it. SF Symbols only. */
|
|
809
|
+
weight?: FontWeight;
|
|
810
|
+
/** Relative to the text size, default `'medium'`. SF Symbols only. */
|
|
811
|
+
scale?: 'small' | 'medium' | 'large';
|
|
812
|
+
/** How much of a variable symbol shows, 0 to 1 — the waves of
|
|
813
|
+
* `speaker.wave.3.fill` at a volume. SF Symbols on macOS 13 and later. */
|
|
814
|
+
variableValue?: number;
|
|
815
|
+
}
|
|
765
816
|
|
|
766
817
|
/**
|
|
767
818
|
* An existing server-side Picture, named by X id. The size is stated by the
|
package/src/types/style.d.ts
CHANGED
|
@@ -305,6 +305,14 @@ export interface PaintStyle {
|
|
|
305
305
|
outlineColor?: Color;
|
|
306
306
|
/** The gap between the border box and the ring. Default 1. */
|
|
307
307
|
outlineOffset?: number;
|
|
308
|
+
/**
|
|
309
|
+
* How opaque the node is **with everything inside it**, from 0 to 1: the
|
|
310
|
+
* subtree is drawn once and composited at this alpha, so a card, its
|
|
311
|
+
* border, its icon and its text fade as one. A paint property — legal in a
|
|
312
|
+
* state block, and it transitions and loops. `0` draws nothing and is
|
|
313
|
+
* still hit; values outside 0..1 are clamped.
|
|
314
|
+
*/
|
|
315
|
+
opacity?: number;
|
|
308
316
|
}
|
|
309
317
|
|
|
310
318
|
/**
|
|
@@ -327,6 +335,34 @@ export type TextOverflow = 'clip' | 'ellipsis';
|
|
|
327
335
|
export type TextRendering =
|
|
328
336
|
'auto' | 'optimizeSpeed' | 'optimizeLegibility' | 'geometricPrecision';
|
|
329
337
|
|
|
338
|
+
/** One `fontVariantNumeric` keyword, and the OpenType feature it turns on. */
|
|
339
|
+
export type NumericVariant =
|
|
340
|
+
| 'lining-nums' // lnum
|
|
341
|
+
| 'oldstyle-nums' // onum
|
|
342
|
+
| 'proportional-nums' // pnum
|
|
343
|
+
| 'tabular-nums' // tnum
|
|
344
|
+
| 'diagonal-fractions' // frac
|
|
345
|
+
| 'stacked-fractions' // afrc
|
|
346
|
+
| 'ordinal' // ordn
|
|
347
|
+
| 'slashed-zero'; // zero
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* CSS's `font-variant-numeric`: `'normal'`, or keywords separated by spaces,
|
|
351
|
+
* at most one of each pair that contradicts — lining or oldstyle,
|
|
352
|
+
* proportional or tabular, diagonal or stacked fractions. Typed as a first
|
|
353
|
+
* keyword and whatever follows; the rest is checked when the style is.
|
|
354
|
+
*/
|
|
355
|
+
export type FontVariantNumeric =
|
|
356
|
+
'normal' | NumericVariant | `${NumericVariant} ${string}`;
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* CSS's `font-feature-settings`, by OpenType tag: the tags to turn on,
|
|
360
|
+
* `['tnum', 'ss01']`, or tag → on or off, or the alternate a feature picks,
|
|
361
|
+
* `{ liga: false, salt: 2 }`.
|
|
362
|
+
*/
|
|
363
|
+
export type FontFeatureSettings =
|
|
364
|
+
readonly string[] | Readonly<Record<string, boolean | number>>;
|
|
365
|
+
|
|
330
366
|
/** Text properties. All affect measurement except `color`. */
|
|
331
367
|
export interface TextStyle {
|
|
332
368
|
color?: Color;
|
|
@@ -346,6 +382,19 @@ export interface TextStyle {
|
|
|
346
382
|
* at any size. `'auto'` (default) lets size decide. Changing it repaints
|
|
347
383
|
* without reflowing: it cannot move anything. */
|
|
348
384
|
textRendering?: TextRendering;
|
|
385
|
+
/** CSS's `letter-spacing`, in pixels: added after every character, the
|
|
386
|
+
* last on a line included, and negative to tighten. Spaced text drops the
|
|
387
|
+
* optional ligatures (`liga`, `clig`, `dlig`, `hlig`) unless
|
|
388
|
+
* `fontFeatureSettings` names them. Inherits. */
|
|
389
|
+
letterSpacing?: number;
|
|
390
|
+
/** Which figures: `'tabular-nums'` gives every digit one width, so a
|
|
391
|
+
* number that changes holds its width while it does. The friendly names
|
|
392
|
+
* for features `fontFeatureSettings` can also set by tag, and it wins
|
|
393
|
+
* where the two meet. Inherits, apart from `fontFeatureSettings`. */
|
|
394
|
+
fontVariantNumeric?: FontVariantNumeric;
|
|
395
|
+
/** Any OpenType feature, by tag. A feature the face does not have is
|
|
396
|
+
* ignored. Compared by value, so an object literal is fine. Inherits. */
|
|
397
|
+
fontFeatureSettings?: FontFeatureSettings;
|
|
349
398
|
textAlign?: TextAlign;
|
|
350
399
|
lineHeight?: number;
|
|
351
400
|
/** CSS's `text-wrap`. `'nowrap'` measures the text at unbounded width, so
|
|
@@ -436,6 +485,14 @@ export interface AnimationSpec {
|
|
|
436
485
|
easing?: Easing;
|
|
437
486
|
/** Turn around at each end instead of wrapping back to `from`. */
|
|
438
487
|
alternate?: boolean;
|
|
488
|
+
/**
|
|
489
|
+
* When the loop's own time starts, in ms — CSS's `animation-delay`. A
|
|
490
|
+
* positive delay holds `from` that long before the first crossing; a
|
|
491
|
+
* negative one starts the loop that far in. Default `0`. What staggers
|
|
492
|
+
* loops of one duration: three dots at `0`, `-150` and `-300` never move
|
|
493
|
+
* together.
|
|
494
|
+
*/
|
|
495
|
+
delay?: number;
|
|
439
496
|
}
|
|
440
497
|
|
|
441
498
|
/**
|
package/src/types/system.d.ts
CHANGED
|
@@ -267,6 +267,110 @@ export interface DesktopSettings {
|
|
|
267
267
|
*/
|
|
268
268
|
export function useDesktopSettings(): DesktopSettings;
|
|
269
269
|
|
|
270
|
+
// --------------------------------------------------------------------------
|
|
271
|
+
// The app's own settings
|
|
272
|
+
// --------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/** A value a settings store can keep: what JSON can. */
|
|
275
|
+
export type SettingValue =
|
|
276
|
+
| string
|
|
277
|
+
| number
|
|
278
|
+
| boolean
|
|
279
|
+
| null
|
|
280
|
+
| readonly SettingValue[]
|
|
281
|
+
| { readonly [key: string]: SettingValue };
|
|
282
|
+
|
|
283
|
+
export interface SettingsOptions<T extends Record<string, SettingValue>> {
|
|
284
|
+
/**
|
|
285
|
+
* A reverse-DNS name no other app uses, like `'com.example.myapp'`: the
|
|
286
|
+
* name of the app's settings directory. Nothing is registered with it.
|
|
287
|
+
*/
|
|
288
|
+
appId: string;
|
|
289
|
+
/** Each setting's value when nothing was saved. */
|
|
290
|
+
defaults?: T;
|
|
291
|
+
/** Where `settings.json` goes instead of the per-user directory for the
|
|
292
|
+
* app — for a portable install, or a test. */
|
|
293
|
+
directory?: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A value's kind: `false` as `boolean`, `'brown'` as `string`, so a
|
|
297
|
+
* default does not narrow what the setting may be set to. */
|
|
298
|
+
export type SettingKind<V> = V extends boolean
|
|
299
|
+
? boolean
|
|
300
|
+
: V extends string
|
|
301
|
+
? string
|
|
302
|
+
: V extends number
|
|
303
|
+
? number
|
|
304
|
+
: V;
|
|
305
|
+
|
|
306
|
+
/** A setting's type: its default's kind, for a key the defaults name, else
|
|
307
|
+
* the fallback's. */
|
|
308
|
+
export type SettingOf<T, K, V> = SettingKind<K extends keyof T ? T[K] : V>;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* What an app remembers between launches, kept in `settings.json` in its
|
|
312
|
+
* per-user directory, written atomically and coalesced.
|
|
313
|
+
*/
|
|
314
|
+
export interface Settings<T extends Record<string, SettingValue>> {
|
|
315
|
+
/** The file the values are kept in. */
|
|
316
|
+
readonly path: string;
|
|
317
|
+
/** `[value, setValue]` for one setting, like `useState`; every component
|
|
318
|
+
* using the key sees the same value, and a change is saved. A key with no
|
|
319
|
+
* default takes a fallback. */
|
|
320
|
+
use<K extends string, V extends SettingValue = never>(
|
|
321
|
+
key: K,
|
|
322
|
+
fallback?: V,
|
|
323
|
+
): [
|
|
324
|
+
SettingOf<T, K, V>,
|
|
325
|
+
(
|
|
326
|
+
value:
|
|
327
|
+
| SettingOf<T, K, V>
|
|
328
|
+
| ((previous: SettingOf<T, K, V>) => SettingOf<T, K, V>),
|
|
329
|
+
) => void,
|
|
330
|
+
];
|
|
331
|
+
get<K extends string, V extends SettingValue = never>(
|
|
332
|
+
key: K,
|
|
333
|
+
fallback?: V,
|
|
334
|
+
): K extends keyof T ? SettingOf<T, K, V> : SettingOf<T, K, V> | undefined;
|
|
335
|
+
/** Change a setting now, and save it a moment later — a quarter second
|
|
336
|
+
* after the last change, and at least once a second while they keep
|
|
337
|
+
* coming. Throws a `TypeError` for a value JSON cannot keep. */
|
|
338
|
+
set<K extends string>(
|
|
339
|
+
key: K,
|
|
340
|
+
value: K extends keyof T ? SettingOf<T, K, never> : SettingValue,
|
|
341
|
+
): void;
|
|
342
|
+
/** Forget what was saved for a setting: its default again. */
|
|
343
|
+
reset(key: string): void;
|
|
344
|
+
/** Save what is waiting now. Resolves when it is on disk. */
|
|
345
|
+
flush(): Promise<void>;
|
|
346
|
+
/** Hear every change; returns the unsubscribe. */
|
|
347
|
+
subscribe(listener: () => void): () => void;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The settings store for an app — one per file in the process, so every call
|
|
352
|
+
* with the same `appId` shares values.
|
|
353
|
+
*
|
|
354
|
+
* ```tsx
|
|
355
|
+
* const settings = createSettings({
|
|
356
|
+
* appId: 'com.example.Hush',
|
|
357
|
+
* defaults: { volume: 0.5, dark: false },
|
|
358
|
+
* });
|
|
359
|
+
* function Volume() {
|
|
360
|
+
* const [volume, setVolume] = settings.use('volume');
|
|
361
|
+
* return <Slider value={volume} onChange={setVolume} />;
|
|
362
|
+
* }
|
|
363
|
+
* ```
|
|
364
|
+
*
|
|
365
|
+
* It lives in `~/Library/Application Support/<appId>/settings.json` on macOS
|
|
366
|
+
* and `$XDG_CONFIG_HOME/<appId>/settings.json` elsewhere, and is read the
|
|
367
|
+
* first time a value is asked for. What is still unsaved when the process
|
|
368
|
+
* exits is written then.
|
|
369
|
+
*/
|
|
370
|
+
export function createSettings<T extends Record<string, SettingValue>>(
|
|
371
|
+
options: SettingsOptions<T>,
|
|
372
|
+
): Settings<T>;
|
|
373
|
+
|
|
270
374
|
// --------------------------------------------------------------------------
|
|
271
375
|
// Locale
|
|
272
376
|
// --------------------------------------------------------------------------
|