UNPKG

14.3 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 * The names of the registered themes.
65 */
66 get themes(): ReadonlyArray<string> {
67 return Object.keys(this._themes);
68 }
69
70 /**
71 * A signal fired when the application theme changes.
72 */
73 get themeChanged(): ISignal<this, IChangedArgs<string, string | null>> {
74 return this._themeChanged;
75 }
76
77 /**
78 * Get the value of a CSS variable from its key.
79 *
80 * @param key - A Jupyterlab CSS variable, without the leading '--jp-'.
81 *
82 * @returns value - The current value of the Jupyterlab CSS variable
83 */
84 getCSS(key: string): string {
85 return (
86 this._overrides[key] ??
87 getComputedStyle(document.documentElement).getPropertyValue(`--jp-${key}`)
88 );
89 }
90
91 /**
92 * Load a theme CSS file by path.
93 *
94 * @param path - The path of the file to load.
95 */
96 loadCSS(path: string): Promise<void> {
97 const base = this._base;
98 const href = URLExt.isLocal(path) ? URLExt.join(base, path) : path;
99 const links = this._links;
100
101 return new Promise((resolve, reject) => {
102 const link = document.createElement('link');
103
104 link.setAttribute('rel', 'stylesheet');
105 link.setAttribute('type', 'text/css');
106 link.setAttribute('href', href);
107 link.addEventListener('load', () => {
108 resolve(undefined);
109 });
110 link.addEventListener('error', () => {
111 reject(`Stylesheet failed to load: ${href}`);
112 });
113
114 document.body.appendChild(link);
115 links.push(link);
116
117 // add any css overrides to document
118 this.loadCSSOverrides();
119 });
120 }
121
122 /**
123 * Loads all current CSS overrides from settings. If an override has been
124 * removed or is invalid, this function unloads it instead.
125 */
126 loadCSSOverrides(): void {
127 const newOverrides =
128 (this._settings.user['overrides'] as Dict<string>) ?? {};
129
130 // iterate over the union of current and new CSS override keys
131 Object.keys({ ...this._overrides, ...newOverrides }).forEach(key => {
132 const val = newOverrides[key];
133
134 if (val && this.validateCSS(key, val)) {
135 // validation succeeded, set the override
136 document.documentElement.style.setProperty(`--jp-${key}`, val);
137 } else {
138 // if key is not present or validation failed, the override will be removed
139 delete newOverrides[key];
140 document.documentElement.style.removeProperty(`--jp-${key}`);
141 }
142 });
143
144 // replace the current overrides with the new ones
145 this._overrides = newOverrides;
146 }
147
148 /**
149 * Validate a CSS value w.r.t. a key
150 *
151 * @param key - A Jupyterlab CSS variable, without the leading '--jp-'.
152 *
153 * @param val - A candidate CSS value
154 */
155 validateCSS(key: string, val: string): boolean {
156 // determine the css property corresponding to the key
157 const prop = this._overrideProps[key];
158
159 if (!prop) {
160 console.warn(
161 'CSS validation failed: could not find property corresponding to key.\n' +
162 `key: '${key}', val: '${val}'`
163 );
164 return false;
165 }
166
167 // use built-in validation once we have the corresponding property
168 if (CSS.supports(prop, val)) {
169 return true;
170 } else {
171 console.warn(
172 'CSS validation failed: invalid value.\n' +
173 `key: '${key}', val: '${val}', prop: '${prop}'`
174 );
175 return false;
176 }
177 }
178
179 /**
180 * Register a theme with the theme manager.
181 *
182 * @param theme - The theme to register.
183 *
184 * @returns A disposable that can be used to unregister the theme.
185 */
186 register(theme: IThemeManager.ITheme): IDisposable {
187 const { name } = theme;
188 const themes = this._themes;
189
190 if (themes[name]) {
191 throw new Error(`Theme already registered for ${name}`);
192 }
193
194 themes[name] = theme;
195
196 return new DisposableDelegate(() => {
197 delete themes[name];
198 });
199 }
200
201 /**
202 * Add a CSS override to the settings.
203 */
204 setCSSOverride(key: string, value: string): Promise<void> {
205 return this._settings.set('overrides', {
206 ...this._overrides,
207 [key]: value
208 });
209 }
210
211 /**
212 * Set the current theme.
213 */
214 setTheme(name: string): Promise<void> {
215 return this._settings.set('theme', name);
216 }
217
218 /**
219 * Test whether a given theme is light.
220 */
221 isLight(name: string): boolean {
222 return this._themes[name].isLight;
223 }
224
225 /**
226 * Increase a font size w.r.t. its current setting or its value in the
227 * current theme.
228 *
229 * @param key - A Jupyterlab font size CSS variable, without the leading '--jp-'.
230 */
231 incrFontSize(key: string): Promise<void> {
232 return this._incrFontSize(key, true);
233 }
234
235 /**
236 * Decrease a font size w.r.t. its current setting or its value in the
237 * current theme.
238 *
239 * @param key - A Jupyterlab font size CSS variable, without the leading '--jp-'.
240 */
241 decrFontSize(key: string): Promise<void> {
242 return this._incrFontSize(key, false);
243 }
244
245 /**
246 * Test whether a given theme styles scrollbars,
247 * and if the user has scrollbar styling enabled.
248 */
249 themeScrollbars(name: string): boolean {
250 return (
251 !!this._settings.composite['theme-scrollbars'] &&
252 !!this._themes[name].themeScrollbars
253 );
254 }
255
256 /**
257 * Test if the user has scrollbar styling enabled.
258 */
259 isToggledThemeScrollbars(): boolean {
260 return !!this._settings.composite['theme-scrollbars'];
261 }
262
263 /**
264 * Toggle the `theme-scrollbars` setting.
265 */
266 toggleThemeScrollbars(): Promise<void> {
267 return this._settings.set(
268 'theme-scrollbars',
269 !this._settings.composite['theme-scrollbars']
270 );
271 }
272
273 /**
274 * Get the display name of the theme.
275 */
276 getDisplayName(name: string): string {
277 return this._themes[name]?.displayName ?? name;
278 }
279
280 /**
281 * Change a font size by a positive or negative increment.
282 */
283 private _incrFontSize(key: string, add: boolean = true): Promise<void> {
284 // get the numeric and unit parts of the current font size
285 const parts = (this.getCSS(key) ?? '13px').split(/([a-zA-Z]+)/);
286
287 // determine the increment
288 const incr = (add ? 1 : -1) * (parts[1] === 'em' ? 0.1 : 1);
289
290 // increment the font size and set it as an override
291 return this.setCSSOverride(key, `${Number(parts[0]) + incr}${parts[1]}`);
292 }
293
294 /**
295 * Initialize the key -> property dict for the overrides
296 */
297 private _initOverrideProps(): void {
298 const definitions = this._settings.schema.definitions as any;
299 const overidesSchema = definitions.cssOverrides.properties;
300
301 Object.keys(overidesSchema).forEach(key => {
302 // override validation is against the CSS property in the description
303 // field. Example: for key ui-font-family, .description is font-family
304 let description;
305 switch (key) {
306 case 'code-font-size':
307 case 'content-font-size1':
308 case 'ui-font-size1':
309 description = 'font-size';
310 break;
311 default:
312 description = overidesSchema[key].description;
313 break;
314 }
315 this._overrideProps[key] = description;
316 });
317 }
318
319 /**
320 * Handle the current settings.
321 */
322 private _loadSettings(): void {
323 const outstanding = this._outstanding;
324 const pending = this._pending;
325 const requests = this._requests;
326
327 // If another request is pending, cancel it.
328 if (pending) {
329 window.clearTimeout(pending);
330 this._pending = 0;
331 }
332
333 const settings = this._settings;
334 const themes = this._themes;
335 const theme = settings.composite['theme'] as string;
336
337 // If another promise is outstanding, wait until it finishes before
338 // attempting to load the settings. Because outstanding promises cannot
339 // be aborted, the order in which they occur must be enforced.
340 if (outstanding) {
341 outstanding
342 .then(() => {
343 this._loadSettings();
344 })
345 .catch(() => {
346 this._loadSettings();
347 });
348 this._outstanding = null;
349 return;
350 }
351
352 // Increment the request counter.
353 requests[theme] = requests[theme] ? requests[theme] + 1 : 1;
354
355 // If the theme exists, load it right away.
356 if (themes[theme]) {
357 this._outstanding = this._loadTheme(theme);
358 delete requests[theme];
359 return;
360 }
361
362 // If the request has taken too long, give up.
363 if (requests[theme] > REQUEST_THRESHOLD) {
364 const fallback = settings.default('theme') as string;
365
366 // Stop tracking the requests for this theme.
367 delete requests[theme];
368
369 if (!themes[fallback]) {
370 this._onError(
371 this._trans.__(
372 'Neither theme %1 nor default %2 loaded.',
373 theme,
374 fallback
375 )
376 );
377 return;
378 }
379
380 console.warn(`Could not load theme ${theme}, using default ${fallback}.`);
381 this._outstanding = this._loadTheme(fallback);
382 return;
383 }
384
385 // If the theme does not yet exist, attempt to wait for it.
386 this._pending = window.setTimeout(() => {
387 this._loadSettings();
388 }, REQUEST_INTERVAL);
389 }
390
391 /**
392 * Load the theme.
393 *
394 * #### Notes
395 * This method assumes that the `theme` exists.
396 */
397 private _loadTheme(theme: string): Promise<void> {
398 const current = this._current;
399 const links = this._links;
400 const themes = this._themes;
401 const splash = this._splash
402 ? this._splash.show(themes[theme].isLight)
403 : new DisposableDelegate(() => undefined);
404
405 // Unload any CSS files that have been loaded.
406 links.forEach(link => {
407 if (link.parentElement) {
408 link.parentElement.removeChild(link);
409 }
410 });
411 links.length = 0;
412
413 const themeProps = this._settings.schema.properties?.theme;
414 if (themeProps) {
415 themeProps.enum = Object.keys(themes).map(
416 value => themes[value].displayName ?? value
417 );
418 }
419
420 // Unload the previously loaded theme.
421 const old = current ? themes[current].unload() : Promise.resolve();
422
423 return Promise.all([old, themes[theme].load()])
424 .then(() => {
425 this._current = theme;
426 this._themeChanged.emit({
427 name: 'theme',
428 oldValue: current,
429 newValue: theme
430 });
431
432 // Need to force a redraw of the app here to avoid a Chrome rendering
433 // bug that can leave the scrollbars in an invalid state
434 this._host.hide();
435
436 // If we hide/show the widget too quickly, no redraw will happen.
437 // requestAnimationFrame delays until after the next frame render.
438 requestAnimationFrame(() => {
439 this._host.show();
440 Private.fitAll(this._host);
441 splash.dispose();
442 });
443 })
444 .catch(reason => {
445 this._onError(reason);
446 splash.dispose();
447 });
448 }
449
450 /**
451 * Handle a theme error.
452 */
453 private _onError(reason: any): void {
454 void showDialog({
455 title: this._trans.__('Error Loading Theme'),
456 body: String(reason),
457 buttons: [Dialog.okButton({ label: this._trans.__('OK') })]
458 });
459 }
460
461 protected translator: ITranslator;
462 private _trans: TranslationBundle;
463 private _base: string;
464 private _current: string | null = null;
465 private _host: Widget;
466 private _links: HTMLLinkElement[] = [];
467 private _overrides: Dict<string> = {};
468 private _overrideProps: Dict<string> = {};
469 private _outstanding: Promise<void> | null = null;
470 private _pending = 0;
471 private _requests: { [theme: string]: number } = {};
472 private _settings: ISettingRegistry.ISettings;
473 private _splash: ISplashScreen | null;
474 private _themes: { [key: string]: IThemeManager.ITheme } = {};
475 private _themeChanged = new Signal<this, IChangedArgs<string, string | null>>(
476 this
477 );
478}
479
480export namespace ThemeManager {
481 /**
482 * The options used to create a theme manager.
483 */
484 export interface IOptions {
485 /**
486 * The host widget for the theme manager.
487 */
488 host: Widget;
489
490 /**
491 * The setting registry key that holds theme setting data.
492 */
493 key: string;
494
495 /**
496 * The settings registry.
497 */
498 settings: ISettingRegistry;
499
500 /**
501 * The splash screen to show when loading themes.
502 */
503 splash?: ISplashScreen;
504
505 /**
506 * The url for local theme loading.
507 */
508 url: string;
509
510 /**
511 * The application language translator.
512 */
513 translator?: ITranslator;
514 }
515}
516
517/**
518 * A namespace for module private data.
519 */
520namespace Private {
521 /**
522 * Fit a widget and all of its children, recursively.
523 */
524 export function fitAll(widget: Widget): void {
525 for (const child of widget.children()) {
526 fitAll(child);
527 }
528 widget.fit();
529 }
530}