UNPKG

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