UNPKG

16.4 kBJavaScriptView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3import { URLExt } from '@jupyterlab/coreutils';
4import { nullTranslator } from '@jupyterlab/translation';
5import { DisposableDelegate } from '@lumino/disposable';
6import { Signal } from '@lumino/signaling';
7import { Dialog, showDialog } from './dialog';
8/**
9 * The number of milliseconds between theme loading attempts.
10 */
11const REQUEST_INTERVAL = 75;
12/**
13 * The number of times to attempt to load a theme before giving up.
14 */
15const REQUEST_THRESHOLD = 20;
16/**
17 * A class that provides theme management.
18 */
19export class ThemeManager {
20 /**
21 * Construct a new theme manager.
22 */
23 constructor(options) {
24 this._current = null;
25 this._links = [];
26 this._overrides = {};
27 this._overrideProps = {};
28 this._outstanding = null;
29 this._pending = 0;
30 this._requests = {};
31 this._themes = {};
32 this._themeChanged = new Signal(this);
33 const { host, key, splash, url } = options;
34 this.translator = options.translator || nullTranslator;
35 this._trans = this.translator.load('jupyterlab');
36 const registry = options.settings;
37 this._base = url;
38 this._host = host;
39 this._splash = splash || null;
40 void registry.load(key).then(settings => {
41 this._settings = settings;
42 // set up css overrides once we have a pointer to the settings schema
43 this._initOverrideProps();
44 this._settings.changed.connect(this._loadSettings, this);
45 this._loadSettings();
46 });
47 }
48 /**
49 * Get the name of the current theme.
50 */
51 get theme() {
52 return this._current;
53 }
54 /**
55 * Get the name of the preferred light theme.
56 */
57 get preferredLightTheme() {
58 return this._settings.composite['preferred-light-theme'];
59 }
60 /**
61 * Get the name of the preferred dark theme.
62 */
63 get preferredDarkTheme() {
64 return this._settings.composite['preferred-dark-theme'];
65 }
66 /**
67 * Get the name of the preferred theme
68 * When `adaptive-theme` is disabled, get current theme;
69 * Else, depending on the system settings, get preferred light or dark theme.
70 */
71 get preferredTheme() {
72 if (!this.isToggledAdaptiveTheme()) {
73 return this.theme;
74 }
75 if (this.isSystemColorSchemeDark()) {
76 return this.preferredDarkTheme;
77 }
78 return this.preferredLightTheme;
79 }
80 /**
81 * The names of the registered themes.
82 */
83 get themes() {
84 return Object.keys(this._themes);
85 }
86 /**
87 * Get the names of the light themes.
88 */
89 get lightThemes() {
90 return Object.entries(this._themes)
91 .filter(([_, theme]) => theme.isLight)
92 .map(([name, _]) => name);
93 }
94 /**
95 * Get the names of the dark themes.
96 */
97 get darkThemes() {
98 return Object.entries(this._themes)
99 .filter(([_, theme]) => !theme.isLight)
100 .map(([name, _]) => name);
101 }
102 /**
103 * A signal fired when the application theme changes.
104 */
105 get themeChanged() {
106 return this._themeChanged;
107 }
108 /**
109 * Test if the system's preferred color scheme is dark
110 */
111 isSystemColorSchemeDark() {
112 return (window.matchMedia &&
113 window.matchMedia('(prefers-color-scheme: dark)').matches);
114 }
115 /**
116 * Get the value of a CSS variable from its key.
117 *
118 * @param key - A Jupyterlab CSS variable, without the leading '--jp-'.
119 *
120 * @returns value - The current value of the Jupyterlab CSS variable
121 */
122 getCSS(key) {
123 var _a;
124 return ((_a = this._overrides[key]) !== null && _a !== void 0 ? _a : getComputedStyle(document.documentElement).getPropertyValue(`--jp-${key}`));
125 }
126 /**
127 * Load a theme CSS file by path.
128 *
129 * @param path - The path of the file to load.
130 */
131 loadCSS(path) {
132 const base = this._base;
133 const href = URLExt.isLocal(path) ? URLExt.join(base, path) : path;
134 const links = this._links;
135 return new Promise((resolve, reject) => {
136 const link = document.createElement('link');
137 link.setAttribute('rel', 'stylesheet');
138 link.setAttribute('type', 'text/css');
139 link.setAttribute('href', href);
140 link.addEventListener('load', () => {
141 resolve(undefined);
142 });
143 link.addEventListener('error', () => {
144 reject(`Stylesheet failed to load: ${href}`);
145 });
146 document.body.appendChild(link);
147 links.push(link);
148 // add any css overrides to document
149 this.loadCSSOverrides();
150 });
151 }
152 /**
153 * Loads all current CSS overrides from settings. If an override has been
154 * removed or is invalid, this function unloads it instead.
155 */
156 loadCSSOverrides() {
157 var _a;
158 const newOverrides = (_a = this._settings.user['overrides']) !== null && _a !== void 0 ? _a : {};
159 // iterate over the union of current and new CSS override keys
160 Object.keys({ ...this._overrides, ...newOverrides }).forEach(key => {
161 const val = newOverrides[key];
162 if (val && this.validateCSS(key, val)) {
163 // validation succeeded, set the override
164 document.documentElement.style.setProperty(`--jp-${key}`, val);
165 }
166 else {
167 // if key is not present or validation failed, the override will be removed
168 delete newOverrides[key];
169 document.documentElement.style.removeProperty(`--jp-${key}`);
170 }
171 });
172 // replace the current overrides with the new ones
173 this._overrides = newOverrides;
174 }
175 /**
176 * Validate a CSS value w.r.t. a key
177 *
178 * @param key - A Jupyterlab CSS variable, without the leading '--jp-'.
179 *
180 * @param val - A candidate CSS value
181 */
182 validateCSS(key, val) {
183 // determine the css property corresponding to the key
184 const prop = this._overrideProps[key];
185 if (!prop) {
186 console.warn('CSS validation failed: could not find property corresponding to key.\n' +
187 `key: '${key}', val: '${val}'`);
188 return false;
189 }
190 // use built-in validation once we have the corresponding property
191 if (CSS.supports(prop, val)) {
192 return true;
193 }
194 else {
195 console.warn('CSS validation failed: invalid value.\n' +
196 `key: '${key}', val: '${val}', prop: '${prop}'`);
197 return false;
198 }
199 }
200 /**
201 * Register a theme with the theme manager.
202 *
203 * @param theme - The theme to register.
204 *
205 * @returns A disposable that can be used to unregister the theme.
206 */
207 register(theme) {
208 const { name } = theme;
209 const themes = this._themes;
210 if (themes[name]) {
211 throw new Error(`Theme already registered for ${name}`);
212 }
213 themes[name] = theme;
214 return new DisposableDelegate(() => {
215 delete themes[name];
216 });
217 }
218 /**
219 * Add a CSS override to the settings.
220 */
221 setCSSOverride(key, value) {
222 return this._settings.set('overrides', {
223 ...this._overrides,
224 [key]: value
225 });
226 }
227 /**
228 * Set the current theme.
229 */
230 setTheme(name) {
231 return this._settings.set('theme', name);
232 }
233 /**
234 * Set the preferred light theme.
235 */
236 setPreferredLightTheme(name) {
237 return this._settings.set('preferred-light-theme', name);
238 }
239 /**
240 * Set the preferred dark theme.
241 */
242 setPreferredDarkTheme(name) {
243 return this._settings.set('preferred-dark-theme', name);
244 }
245 /**
246 * Test whether a given theme is light.
247 */
248 isLight(name) {
249 return this._themes[name].isLight;
250 }
251 /**
252 * Increase a font size w.r.t. its current setting or its value in the
253 * current theme.
254 *
255 * @param key - A Jupyterlab font size CSS variable, without the leading '--jp-'.
256 */
257 incrFontSize(key) {
258 return this._incrFontSize(key, true);
259 }
260 /**
261 * Decrease a font size w.r.t. its current setting or its value in the
262 * current theme.
263 *
264 * @param key - A Jupyterlab font size CSS variable, without the leading '--jp-'.
265 */
266 decrFontSize(key) {
267 return this._incrFontSize(key, false);
268 }
269 /**
270 * Test whether a given theme styles scrollbars,
271 * and if the user has scrollbar styling enabled.
272 */
273 themeScrollbars(name) {
274 return (!!this._settings.composite['theme-scrollbars'] &&
275 !!this._themes[name].themeScrollbars);
276 }
277 /**
278 * Test if the user has scrollbar styling enabled.
279 */
280 isToggledThemeScrollbars() {
281 return !!this._settings.composite['theme-scrollbars'];
282 }
283 /**
284 * Toggle the `theme-scrollbars` setting.
285 */
286 toggleThemeScrollbars() {
287 return this._settings.set('theme-scrollbars', !this._settings.composite['theme-scrollbars']);
288 }
289 /**
290 * Test if the user enables adaptive theme.
291 */
292 isToggledAdaptiveTheme() {
293 return !!this._settings.composite['adaptive-theme'];
294 }
295 /**
296 * Toggle the `adaptive-theme` setting.
297 */
298 toggleAdaptiveTheme() {
299 return this._settings.set('adaptive-theme', !this._settings.composite['adaptive-theme']);
300 }
301 /**
302 * Get the display name of the theme.
303 */
304 getDisplayName(name) {
305 var _a, _b;
306 return (_b = (_a = this._themes[name]) === null || _a === void 0 ? void 0 : _a.displayName) !== null && _b !== void 0 ? _b : name;
307 }
308 /**
309 * Change a font size by a positive or negative increment.
310 */
311 _incrFontSize(key, add = true) {
312 var _a;
313 // get the numeric and unit parts of the current font size
314 const parts = ((_a = this.getCSS(key)) !== null && _a !== void 0 ? _a : '13px').split(/([a-zA-Z]+)/);
315 // determine the increment
316 const incr = (add ? 1 : -1) * (parts[1] === 'em' ? 0.1 : 1);
317 // increment the font size and set it as an override
318 return this.setCSSOverride(key, `${Number(parts[0]) + incr}${parts[1]}`);
319 }
320 /**
321 * Initialize the key -> property dict for the overrides
322 */
323 _initOverrideProps() {
324 const definitions = this._settings.schema.definitions;
325 const overidesSchema = definitions.cssOverrides.properties;
326 Object.keys(overidesSchema).forEach(key => {
327 // override validation is against the CSS property in the description
328 // field. Example: for key ui-font-family, .description is font-family
329 let description;
330 switch (key) {
331 case 'code-font-size':
332 case 'content-font-size1':
333 case 'ui-font-size1':
334 description = 'font-size';
335 break;
336 default:
337 description = overidesSchema[key].description;
338 break;
339 }
340 this._overrideProps[key] = description;
341 });
342 }
343 /**
344 * Handle the current settings.
345 */
346 _loadSettings() {
347 const outstanding = this._outstanding;
348 const pending = this._pending;
349 const requests = this._requests;
350 // If another request is pending, cancel it.
351 if (pending) {
352 window.clearTimeout(pending);
353 this._pending = 0;
354 }
355 const settings = this._settings;
356 const themes = this._themes;
357 let theme = settings.composite['theme'];
358 if (this.isToggledAdaptiveTheme()) {
359 if (this.isSystemColorSchemeDark()) {
360 theme = this.preferredDarkTheme;
361 }
362 else {
363 theme = this.preferredLightTheme;
364 }
365 }
366 // If another promise is outstanding, wait until it finishes before
367 // attempting to load the settings. Because outstanding promises cannot
368 // be aborted, the order in which they occur must be enforced.
369 if (outstanding) {
370 outstanding
371 .then(() => {
372 this._loadSettings();
373 })
374 .catch(() => {
375 this._loadSettings();
376 });
377 this._outstanding = null;
378 return;
379 }
380 // Increment the request counter.
381 requests[theme] = requests[theme] ? requests[theme] + 1 : 1;
382 // If the theme exists, load it right away.
383 if (themes[theme]) {
384 this._outstanding = this._loadTheme(theme);
385 delete requests[theme];
386 return;
387 }
388 // If the request has taken too long, give up.
389 if (requests[theme] > REQUEST_THRESHOLD) {
390 const fallback = settings.default('theme');
391 // Stop tracking the requests for this theme.
392 delete requests[theme];
393 if (!themes[fallback]) {
394 this._onError(this._trans.__('Neither theme %1 nor default %2 loaded.', theme, fallback));
395 return;
396 }
397 console.warn(`Could not load theme ${theme}, using default ${fallback}.`);
398 this._outstanding = this._loadTheme(fallback);
399 return;
400 }
401 // If the theme does not yet exist, attempt to wait for it.
402 this._pending = window.setTimeout(() => {
403 this._loadSettings();
404 }, REQUEST_INTERVAL);
405 }
406 /**
407 * Load the theme.
408 *
409 * #### Notes
410 * This method assumes that the `theme` exists.
411 */
412 _loadTheme(theme) {
413 var _a;
414 const current = this._current;
415 const links = this._links;
416 const themes = this._themes;
417 const splash = this._splash
418 ? this._splash.show(themes[theme].isLight)
419 : new DisposableDelegate(() => undefined);
420 // Unload any CSS files that have been loaded.
421 links.forEach(link => {
422 if (link.parentElement) {
423 link.parentElement.removeChild(link);
424 }
425 });
426 links.length = 0;
427 const themeProps = (_a = this._settings.schema.properties) === null || _a === void 0 ? void 0 : _a.theme;
428 if (themeProps) {
429 themeProps.enum = Object.keys(themes).map(value => { var _a; return (_a = themes[value].displayName) !== null && _a !== void 0 ? _a : value; });
430 }
431 // Unload the previously loaded theme.
432 const old = current ? themes[current].unload() : Promise.resolve();
433 return Promise.all([old, themes[theme].load()])
434 .then(() => {
435 this._current = theme;
436 this._themeChanged.emit({
437 name: 'theme',
438 oldValue: current,
439 newValue: theme
440 });
441 // Need to force a redraw of the app here to avoid a Chrome rendering
442 // bug that can leave the scrollbars in an invalid state
443 this._host.hide();
444 // If we hide/show the widget too quickly, no redraw will happen.
445 // requestAnimationFrame delays until after the next frame render.
446 requestAnimationFrame(() => {
447 this._host.show();
448 Private.fitAll(this._host);
449 splash.dispose();
450 });
451 })
452 .catch(reason => {
453 this._onError(reason);
454 splash.dispose();
455 });
456 }
457 /**
458 * Handle a theme error.
459 */
460 _onError(reason) {
461 void showDialog({
462 title: this._trans.__('Error Loading Theme'),
463 body: String(reason),
464 buttons: [Dialog.okButton({ label: this._trans.__('OK') })]
465 });
466 }
467}
468/**
469 * A namespace for module private data.
470 */
471var Private;
472(function (Private) {
473 /**
474 * Fit a widget and all of its children, recursively.
475 */
476 function fitAll(widget) {
477 for (const child of widget.children()) {
478 fitAll(child);
479 }
480 widget.fit();
481 }
482 Private.fitAll = fitAll;
483})(Private || (Private = {}));
484//# sourceMappingURL=thememanager.js.map
\No newline at end of file