UNPKG

@jupyterlab/apputils

Version:
484 lines 16.4 kB
// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import { URLExt } from '@jupyterlab/coreutils'; import { nullTranslator } from '@jupyterlab/translation'; import { DisposableDelegate } from '@lumino/disposable'; import { Signal } from '@lumino/signaling'; import { Dialog, showDialog } from './dialog'; /** * The number of milliseconds between theme loading attempts. */ const REQUEST_INTERVAL = 75; /** * The number of times to attempt to load a theme before giving up. */ const REQUEST_THRESHOLD = 20; /** * A class that provides theme management. */ export class ThemeManager { /** * Construct a new theme manager. */ constructor(options) { this._current = null; this._links = []; this._overrides = {}; this._overrideProps = {}; this._outstanding = null; this._pending = 0; this._requests = {}; this._themes = {}; this._themeChanged = new Signal(this); const { host, key, splash, url } = options; this.translator = options.translator || nullTranslator; this._trans = this.translator.load('jupyterlab'); const registry = options.settings; this._base = url; this._host = host; this._splash = splash || null; void registry.load(key).then(settings => { this._settings = settings; // set up css overrides once we have a pointer to the settings schema this._initOverrideProps(); this._settings.changed.connect(this._loadSettings, this); this._loadSettings(); }); } /** * Get the name of the current theme. */ get theme() { return this._current; } /** * Get the name of the preferred light theme. */ get preferredLightTheme() { return this._settings.composite['preferred-light-theme']; } /** * Get the name of the preferred dark theme. */ get preferredDarkTheme() { return this._settings.composite['preferred-dark-theme']; } /** * Get the name of the preferred theme * When `adaptive-theme` is disabled, get current theme; * Else, depending on the system settings, get preferred light or dark theme. */ get preferredTheme() { if (!this.isToggledAdaptiveTheme()) { return this.theme; } if (this.isSystemColorSchemeDark()) { return this.preferredDarkTheme; } return this.preferredLightTheme; } /** * The names of the registered themes. */ get themes() { return Object.keys(this._themes); } /** * Get the names of the light themes. */ get lightThemes() { return Object.entries(this._themes) .filter(([_, theme]) => theme.isLight) .map(([name, _]) => name); } /** * Get the names of the dark themes. */ get darkThemes() { return Object.entries(this._themes) .filter(([_, theme]) => !theme.isLight) .map(([name, _]) => name); } /** * A signal fired when the application theme changes. */ get themeChanged() { return this._themeChanged; } /** * Test if the system's preferred color scheme is dark */ isSystemColorSchemeDark() { return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); } /** * Get the value of a CSS variable from its key. * * @param key - A Jupyterlab CSS variable, without the leading '--jp-'. * * @returns value - The current value of the Jupyterlab CSS variable */ getCSS(key) { var _a; return ((_a = this._overrides[key]) !== null && _a !== void 0 ? _a : getComputedStyle(document.documentElement).getPropertyValue(`--jp-${key}`)); } /** * Load a theme CSS file by path. * * @param path - The path of the file to load. */ loadCSS(path) { const base = this._base; const href = URLExt.isLocal(path) ? URLExt.join(base, path) : path; const links = this._links; return new Promise((resolve, reject) => { const link = document.createElement('link'); link.setAttribute('rel', 'stylesheet'); link.setAttribute('type', 'text/css'); link.setAttribute('href', href); link.addEventListener('load', () => { resolve(undefined); }); link.addEventListener('error', () => { reject(`Stylesheet failed to load: ${href}`); }); document.body.appendChild(link); links.push(link); // add any css overrides to document this.loadCSSOverrides(); }); } /** * Loads all current CSS overrides from settings. If an override has been * removed or is invalid, this function unloads it instead. */ loadCSSOverrides() { var _a; const newOverrides = (_a = this._settings.user['overrides']) !== null && _a !== void 0 ? _a : {}; // iterate over the union of current and new CSS override keys Object.keys({ ...this._overrides, ...newOverrides }).forEach(key => { const val = newOverrides[key]; if (val && this.validateCSS(key, val)) { // validation succeeded, set the override document.documentElement.style.setProperty(`--jp-${key}`, val); } else { // if key is not present or validation failed, the override will be removed delete newOverrides[key]; document.documentElement.style.removeProperty(`--jp-${key}`); } }); // replace the current overrides with the new ones this._overrides = newOverrides; } /** * Validate a CSS value w.r.t. a key * * @param key - A Jupyterlab CSS variable, without the leading '--jp-'. * * @param val - A candidate CSS value */ validateCSS(key, val) { // determine the css property corresponding to the key const prop = this._overrideProps[key]; if (!prop) { console.warn('CSS validation failed: could not find property corresponding to key.\n' + `key: '${key}', val: '${val}'`); return false; } // use built-in validation once we have the corresponding property if (CSS.supports(prop, val)) { return true; } else { console.warn('CSS validation failed: invalid value.\n' + `key: '${key}', val: '${val}', prop: '${prop}'`); return false; } } /** * Register a theme with the theme manager. * * @param theme - The theme to register. * * @returns A disposable that can be used to unregister the theme. */ register(theme) { const { name } = theme; const themes = this._themes; if (themes[name]) { throw new Error(`Theme already registered for ${name}`); } themes[name] = theme; return new DisposableDelegate(() => { delete themes[name]; }); } /** * Add a CSS override to the settings. */ setCSSOverride(key, value) { return this._settings.set('overrides', { ...this._overrides, [key]: value }); } /** * Set the current theme. */ setTheme(name) { return this._settings.set('theme', name); } /** * Set the preferred light theme. */ setPreferredLightTheme(name) { return this._settings.set('preferred-light-theme', name); } /** * Set the preferred dark theme. */ setPreferredDarkTheme(name) { return this._settings.set('preferred-dark-theme', name); } /** * Test whether a given theme is light. */ isLight(name) { return this._themes[name].isLight; } /** * Increase a font size w.r.t. its current setting or its value in the * current theme. * * @param key - A Jupyterlab font size CSS variable, without the leading '--jp-'. */ incrFontSize(key) { return this._incrFontSize(key, true); } /** * Decrease a font size w.r.t. its current setting or its value in the * current theme. * * @param key - A Jupyterlab font size CSS variable, without the leading '--jp-'. */ decrFontSize(key) { return this._incrFontSize(key, false); } /** * Test whether a given theme styles scrollbars, * and if the user has scrollbar styling enabled. */ themeScrollbars(name) { return (!!this._settings.composite['theme-scrollbars'] && !!this._themes[name].themeScrollbars); } /** * Test if the user has scrollbar styling enabled. */ isToggledThemeScrollbars() { return !!this._settings.composite['theme-scrollbars']; } /** * Toggle the `theme-scrollbars` setting. */ toggleThemeScrollbars() { return this._settings.set('theme-scrollbars', !this._settings.composite['theme-scrollbars']); } /** * Test if the user enables adaptive theme. */ isToggledAdaptiveTheme() { return !!this._settings.composite['adaptive-theme']; } /** * Toggle the `adaptive-theme` setting. */ toggleAdaptiveTheme() { return this._settings.set('adaptive-theme', !this._settings.composite['adaptive-theme']); } /** * Get the display name of the theme. */ getDisplayName(name) { var _a, _b; return (_b = (_a = this._themes[name]) === null || _a === void 0 ? void 0 : _a.displayName) !== null && _b !== void 0 ? _b : name; } /** * Change a font size by a positive or negative increment. */ _incrFontSize(key, add = true) { var _a; // get the numeric and unit parts of the current font size const parts = ((_a = this.getCSS(key)) !== null && _a !== void 0 ? _a : '13px').split(/([a-zA-Z]+)/); // determine the increment const incr = (add ? 1 : -1) * (parts[1] === 'em' ? 0.1 : 1); // increment the font size and set it as an override return this.setCSSOverride(key, `${Number(parts[0]) + incr}${parts[1]}`); } /** * Initialize the key -> property dict for the overrides */ _initOverrideProps() { const definitions = this._settings.schema.definitions; const overidesSchema = definitions.cssOverrides.properties; Object.keys(overidesSchema).forEach(key => { // override validation is against the CSS property in the description // field. Example: for key ui-font-family, .description is font-family let description; switch (key) { case 'code-font-size': case 'content-font-size1': case 'ui-font-size1': description = 'font-size'; break; default: description = overidesSchema[key].description; break; } this._overrideProps[key] = description; }); } /** * Handle the current settings. */ _loadSettings() { const outstanding = this._outstanding; const pending = this._pending; const requests = this._requests; // If another request is pending, cancel it. if (pending) { window.clearTimeout(pending); this._pending = 0; } const settings = this._settings; const themes = this._themes; let theme = settings.composite['theme']; if (this.isToggledAdaptiveTheme()) { if (this.isSystemColorSchemeDark()) { theme = this.preferredDarkTheme; } else { theme = this.preferredLightTheme; } } // If another promise is outstanding, wait until it finishes before // attempting to load the settings. Because outstanding promises cannot // be aborted, the order in which they occur must be enforced. if (outstanding) { outstanding .then(() => { this._loadSettings(); }) .catch(() => { this._loadSettings(); }); this._outstanding = null; return; } // Increment the request counter. requests[theme] = requests[theme] ? requests[theme] + 1 : 1; // If the theme exists, load it right away. if (themes[theme]) { this._outstanding = this._loadTheme(theme); delete requests[theme]; return; } // If the request has taken too long, give up. if (requests[theme] > REQUEST_THRESHOLD) { const fallback = settings.default('theme'); // Stop tracking the requests for this theme. delete requests[theme]; if (!themes[fallback]) { this._onError(this._trans.__('Neither theme %1 nor default %2 loaded.', theme, fallback)); return; } console.warn(`Could not load theme ${theme}, using default ${fallback}.`); this._outstanding = this._loadTheme(fallback); return; } // If the theme does not yet exist, attempt to wait for it. this._pending = window.setTimeout(() => { this._loadSettings(); }, REQUEST_INTERVAL); } /** * Load the theme. * * #### Notes * This method assumes that the `theme` exists. */ _loadTheme(theme) { var _a; const current = this._current; const links = this._links; const themes = this._themes; const splash = this._splash ? this._splash.show(themes[theme].isLight) : new DisposableDelegate(() => undefined); // Unload any CSS files that have been loaded. links.forEach(link => { if (link.parentElement) { link.parentElement.removeChild(link); } }); links.length = 0; const themeProps = (_a = this._settings.schema.properties) === null || _a === void 0 ? void 0 : _a.theme; if (themeProps) { themeProps.enum = Object.keys(themes).map(value => { var _a; return (_a = themes[value].displayName) !== null && _a !== void 0 ? _a : value; }); } // Unload the previously loaded theme. const old = current ? themes[current].unload() : Promise.resolve(); return Promise.all([old, themes[theme].load()]) .then(() => { this._current = theme; this._themeChanged.emit({ name: 'theme', oldValue: current, newValue: theme }); // Need to force a redraw of the app here to avoid a Chrome rendering // bug that can leave the scrollbars in an invalid state this._host.hide(); // If we hide/show the widget too quickly, no redraw will happen. // requestAnimationFrame delays until after the next frame render. requestAnimationFrame(() => { this._host.show(); Private.fitAll(this._host); splash.dispose(); }); }) .catch(reason => { this._onError(reason); splash.dispose(); }); } /** * Handle a theme error. */ _onError(reason) { void showDialog({ title: this._trans.__('Error Loading Theme'), body: String(reason), buttons: [Dialog.okButton({ label: this._trans.__('OK') })] }); } } /** * A namespace for module private data. */ var Private; (function (Private) { /** * Fit a widget and all of its children, recursively. */ function fitAll(widget) { for (const child of widget.children()) { fitAll(child); } widget.fit(); } Private.fitAll = fitAll; })(Private || (Private = {})); //# sourceMappingURL=thememanager.js.map