UNPKG

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