diff --git a/packages/application/style/scrollbar.css b/packages/application/style/scrollbar.css index fe92a9fccdaa..a8630d3c01d3 100644 --- a/packages/application/style/scrollbar.css +++ b/packages/application/style/scrollbar.css @@ -8,12 +8,13 @@ */ /* use standard opaque scrollbars for most nodes */ -div.jp-LabShell[data-jp-theme-scrollbars='true'] { +[data-jp-theme-scrollbars='true'] { scrollbar-color: rgb(var(--jp-scrollbar-thumb-color)) var(--jp-scrollbar-background-color); } -/* for code nodes, use a transparent style of scrollbar */ +/* for code nodes, use a transparent style of scrollbar. These selectors + * will match lower in the tree, and so will override the above */ [data-jp-theme-scrollbars='true'] .CodeMirror-hscrollbar, [data-jp-theme-scrollbars='true'] .CodeMirror-vscrollbar { scrollbar-color: rgba(var(--jp-scrollbar-thumb-color), 0.5) transparent; diff --git a/packages/apputils-extension/schema/themes.json b/packages/apputils-extension/schema/themes.json index e00dfafe1b55..e06a7a9412b6 100644 --- a/packages/apputils-extension/schema/themes.json +++ b/packages/apputils-extension/schema/themes.json @@ -2,7 +2,29 @@ "title": "Theme", "jupyter.lab.setting-icon-label": "Theme Manager", "description": "Theme manager settings.", + "type": "object", "additionalProperties": false, + "definitions": { + "cssOverrides": { + "type": "object", + "additionalProperties": false, + "description": "The description field of each item is the CSS property that will be used to validate an override's value", + "properties": { + "code-font-size": { + "type": ["string", "null"], + "description": "font-size" + }, + "content-font-size1": { + "type": ["string", "null"], + "description": "font-size" + }, + "ui-font-size1": { + "type": ["string", "null"], + "description": "font-size" + } + } + } + }, "properties": { "theme": { "type": "string", @@ -15,7 +37,16 @@ "title": "Scrollbar Theming", "description": "Enable/disable styling of the application scrollbars", "default": false + }, + "overrides": { + "title": "Theme CSS Overrides", + "description": "Override theme CSS variables by setting key-value pairs here", + "$ref": "#/definitions/cssOverrides", + "default": { + "code-font-size": null, + "content-font-size1": null, + "ui-font-size1": null + } } - }, - "type": "object" + } } diff --git a/packages/apputils-extension/src/index.ts b/packages/apputils-extension/src/index.ts index c07def7b84f6..9f1dcdf11c74 100644 --- a/packages/apputils-extension/src/index.ts +++ b/packages/apputils-extension/src/index.ts @@ -14,9 +14,7 @@ import { Dialog, ICommandPalette, ISplashScreen, - IThemeManager, IWindowResolver, - ThemeManager, WindowResolver, Printing } from '@jupyterlab/apputils'; @@ -32,18 +30,16 @@ import { URLExt } from '@jupyterlab/coreutils'; -import { IMainMenu } from '@jupyterlab/mainmenu'; - import { defaultIconRegistry } from '@jupyterlab/ui-components'; import { PromiseDelegate } from '@phosphor/coreutils'; import { DisposableDelegate } from '@phosphor/disposable'; -import { Menu } from '@phosphor/widgets'; - import { Palette } from './palette'; +import { themesPlugin, themesPaletteMenuPlugin } from './themeplugins'; + /** * The interval in milliseconds before recover options appear during splash. */ @@ -53,8 +49,6 @@ const SPLASH_RECOVER_TIMEOUT = 12000; * The command IDs used by the apputils plugin. */ namespace CommandIDs { - export const changeTheme = 'apputils:change-theme'; - export const loadState = 'apputils:load-statedb'; export const print = 'apputils:print'; @@ -105,128 +99,6 @@ const settings: JupyterFrontEndPlugin = { provides: ISettingRegistry }; -/** - * The default theme manager provider. - */ -const themes: JupyterFrontEndPlugin = { - id: '@jupyterlab/apputils-extension:themes', - requires: [ISettingRegistry, JupyterFrontEnd.IPaths], - optional: [ISplashScreen], - activate: ( - app: JupyterFrontEnd, - settings: ISettingRegistry, - paths: JupyterFrontEnd.IPaths, - splash: ISplashScreen | null - ): IThemeManager => { - const host = app.shell; - const commands = app.commands; - const url = URLExt.join(paths.urls.base, paths.urls.themes); - const key = themes.id; - const manager = new ThemeManager({ key, host, settings, splash, url }); - - // Keep a synchronously set reference to the current theme, - // since the asynchronous setting of the theme in `changeTheme` - // can lead to an incorrect toggle on the currently used theme. - let currentTheme: string; - - // Set data attributes on the application shell for the current theme. - manager.themeChanged.connect((sender, args) => { - currentTheme = args.newValue; - document.body.dataset.jpThemeLight = String( - manager.isLight(currentTheme) - ); - document.body.dataset.jpThemeName = currentTheme; - if ( - document.body.dataset.jpThemeScrollbars !== - String(manager.themeScrollbars(currentTheme)) - ) { - document.body.dataset.jpThemeScrollbars = String( - manager.themeScrollbars(currentTheme) - ); - } - commands.notifyCommandChanged(CommandIDs.changeTheme); - }); - - commands.addCommand(CommandIDs.changeTheme, { - label: args => { - const theme = args['theme'] as string; - return args['isPalette'] ? `Use ${theme} Theme` : theme; - }, - isToggled: args => args['theme'] === currentTheme, - execute: args => { - const theme = args['theme'] as string; - if (theme === manager.theme) { - return; - } - return manager.setTheme(theme); - } - }); - - return manager; - }, - autoStart: true, - provides: IThemeManager -}; - -/** - * The default theme manager's UI command palette and main menu functionality. - * - * #### Notes - * This plugin loads separately from the theme manager plugin in order to - * prevent blocking of the theme manager while it waits for the command palette - * and main menu to become available. - */ -const themesPaletteMenu: JupyterFrontEndPlugin = { - id: '@jupyterlab/apputils-extension:themes-palette-menu', - requires: [IThemeManager], - optional: [ICommandPalette, IMainMenu], - activate: ( - app: JupyterFrontEnd, - manager: IThemeManager, - palette: ICommandPalette | null, - mainMenu: IMainMenu | null - ): void => { - const commands = app.commands; - - // If we have a main menu, add the theme manager to the settings menu. - if (mainMenu) { - const themeMenu = new Menu({ commands }); - themeMenu.title.label = 'JupyterLab Theme'; - void app.restored.then(() => { - const command = CommandIDs.changeTheme; - const isPalette = false; - - manager.themes.forEach(theme => { - themeMenu.addItem({ command, args: { isPalette, theme } }); - }); - }); - mainMenu.settingsMenu.addGroup( - [ - { - type: 'submenu' as Menu.ItemType, - submenu: themeMenu - } - ], - 0 - ); - } - - // If we have a command palette, add theme switching options to it. - if (palette) { - void app.restored.then(() => { - const category = 'Settings'; - const command = CommandIDs.changeTheme; - const isPalette = true; - - manager.themes.forEach(theme => { - palette.addItem({ command, args: { isPalette, theme }, category }); - }); - }); - } - }, - autoStart: true -}; - /** * The default window name resolver provider. */ @@ -594,8 +466,8 @@ const plugins: JupyterFrontEndPlugin[] = [ settings, state, splash, - themes, - themesPaletteMenu, + themesPlugin, + themesPaletteMenuPlugin, print ]; export default plugins; diff --git a/packages/apputils-extension/src/themeplugins.ts b/packages/apputils-extension/src/themeplugins.ts new file mode 100644 index 000000000000..6ee4d3458aec --- /dev/null +++ b/packages/apputils-extension/src/themeplugins.ts @@ -0,0 +1,252 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ + +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; + +import { + ICommandPalette, + ISplashScreen, + IThemeManager, + ThemeManager +} from '@jupyterlab/apputils'; + +import { ISettingRegistry, URLExt } from '@jupyterlab/coreutils'; + +import { IMainMenu } from '@jupyterlab/mainmenu'; + +import { Menu } from '@phosphor/widgets'; + +namespace CommandIDs { + export const changeTheme = 'apputils:change-theme'; + + export const themeScrollbars = 'apputils:theme-scrollbars'; + + export const incrFontSize = 'apputils:incr-font-size'; + + export const decrFontSize = 'apputils:decr-font-size'; +} + +/** + * The default theme manager provider. + */ +export const themesPlugin: JupyterFrontEndPlugin = { + id: '@jupyterlab/apputils-extension:themes', + requires: [ISettingRegistry, JupyterFrontEnd.IPaths], + optional: [ISplashScreen], + activate: ( + app: JupyterFrontEnd, + settings: ISettingRegistry, + paths: JupyterFrontEnd.IPaths, + splash: ISplashScreen | null + ): IThemeManager => { + const host = app.shell; + const commands = app.commands; + const url = URLExt.join(paths.urls.base, paths.urls.themes); + const key = themesPlugin.id; + const manager = new ThemeManager({ key, host, settings, splash, url }); + + // Keep a synchronously set reference to the current theme, + // since the asynchronous setting of the theme in `changeTheme` + // can lead to an incorrect toggle on the currently used theme. + let currentTheme: string; + + manager.themeChanged.connect((sender, args) => { + // Set data attributes on the application shell for the current theme. + currentTheme = args.newValue; + document.body.dataset.jpThemeLight = String( + manager.isLight(currentTheme) + ); + document.body.dataset.jpThemeName = currentTheme; + if ( + document.body.dataset.jpThemeScrollbars !== + String(manager.themeScrollbars(currentTheme)) + ) { + document.body.dataset.jpThemeScrollbars = String( + manager.themeScrollbars(currentTheme) + ); + } + + // Set any CSS overrides + manager.loadCSSOverrides(); + + commands.notifyCommandChanged(CommandIDs.changeTheme); + }); + + commands.addCommand(CommandIDs.changeTheme, { + label: args => { + const theme = args['theme'] as string; + return args['isPalette'] ? `Use ${theme} Theme` : theme; + }, + isToggled: args => args['theme'] === currentTheme, + execute: args => { + const theme = args['theme'] as string; + if (theme === manager.theme) { + return; + } + return manager.setTheme(theme); + } + }); + + commands.addCommand(CommandIDs.themeScrollbars, { + label: 'Theme Scrollbars', + isToggled: () => manager.isToggledThemeScrollbars(), + execute: () => manager.toggleThemeScrollbars() + }); + + commands.addCommand(CommandIDs.incrFontSize, { + label: args => `Increase ${args['label']} Font Size`, + execute: args => manager.incrFontSize(args['key'] as string) + }); + + commands.addCommand(CommandIDs.decrFontSize, { + label: args => `Decrease ${args['label']} Font Size`, + execute: args => manager.decrFontSize(args['key'] as string) + }); + + return manager; + }, + autoStart: true, + provides: IThemeManager +}; + +/** + * The default theme manager's UI command palette and main menu functionality. + * + * #### Notes + * This plugin loads separately from the theme manager plugin in order to + * prevent blocking of the theme manager while it waits for the command palette + * and main menu to become available. + */ +export const themesPaletteMenuPlugin: JupyterFrontEndPlugin = { + id: '@jupyterlab/apputils-extension:themes-palette-menu', + requires: [IThemeManager], + optional: [ICommandPalette, IMainMenu], + activate: ( + app: JupyterFrontEnd, + manager: IThemeManager, + palette: ICommandPalette | null, + mainMenu: IMainMenu | null + ): void => { + const commands = app.commands; + + // If we have a main menu, add the theme manager to the settings menu. + if (mainMenu) { + const themeMenu = new Menu({ commands }); + themeMenu.title.label = 'JupyterLab Theme'; + void app.restored.then(() => { + const isPalette = false; + + // choose a theme + manager.themes.forEach(theme => { + themeMenu.addItem({ + command: CommandIDs.changeTheme, + args: { isPalette, theme } + }); + }); + themeMenu.addItem({ type: 'separator' }); + + // toggle scrollbar theming + themeMenu.addItem({ command: CommandIDs.themeScrollbars }); + themeMenu.addItem({ type: 'separator' }); + + // increase/decrease code font size + themeMenu.addItem({ + command: CommandIDs.incrFontSize, + args: { label: 'Code', key: 'code-font-size' } + }); + themeMenu.addItem({ + command: CommandIDs.decrFontSize, + args: { label: 'Code', key: 'code-font-size' } + }); + themeMenu.addItem({ type: 'separator' }); + + // increase/decrease content font size + themeMenu.addItem({ + command: CommandIDs.incrFontSize, + args: { label: 'Content', key: 'content-font-size1' } + }); + themeMenu.addItem({ + command: CommandIDs.decrFontSize, + args: { label: 'Content', key: 'content-font-size1' } + }); + themeMenu.addItem({ type: 'separator' }); + + // increase/decrease ui font size + themeMenu.addItem({ + command: CommandIDs.incrFontSize, + args: { label: 'UI', key: 'ui-font-size1' } + }); + themeMenu.addItem({ + command: CommandIDs.decrFontSize, + args: { label: 'UI', key: 'ui-font-size1' } + }); + }); + mainMenu.settingsMenu.addGroup( + [ + { + type: 'submenu' as Menu.ItemType, + submenu: themeMenu + } + ], + 0 + ); + } + + // If we have a command palette, add theme switching options to it. + if (palette) { + void app.restored.then(() => { + const category = 'Theme'; + const command = CommandIDs.changeTheme; + const isPalette = true; + + // choose a theme + manager.themes.forEach(theme => { + palette.addItem({ command, args: { isPalette, theme }, category }); + }); + + // toggle scrollbar theming + palette.addItem({ command: CommandIDs.themeScrollbars, category }); + + // increase/decrease code font size + palette.addItem({ + command: CommandIDs.incrFontSize, + args: { label: 'Code', key: 'code-font-size' }, + category + }); + palette.addItem({ + command: CommandIDs.decrFontSize, + args: { label: 'Code', key: 'code-font-size' }, + category + }); + // increase/decrease content font size + palette.addItem({ + command: CommandIDs.incrFontSize, + args: { label: 'Content', key: 'content-font-size1' }, + category + }); + palette.addItem({ + command: CommandIDs.decrFontSize, + args: { label: 'Content', key: 'content-font-size1' }, + category + }); + // increase/decrease ui font size + palette.addItem({ + command: CommandIDs.incrFontSize, + args: { label: 'UI', key: 'ui-font-size1' }, + category + }); + palette.addItem({ + command: CommandIDs.decrFontSize, + args: { label: 'UI', key: 'ui-font-size1' }, + category + }); + }); + } + }, + autoStart: true +}; diff --git a/packages/apputils/src/thememanager.ts b/packages/apputils/src/thememanager.ts index 1e03f66f882b..97a41fa7682b 100644 --- a/packages/apputils/src/thememanager.ts +++ b/packages/apputils/src/thememanager.ts @@ -27,6 +27,8 @@ const REQUEST_INTERVAL = 75; */ const REQUEST_THRESHOLD = 20; +type Dict = { [key: string]: T }; + /** * A class that provides theme management. */ @@ -46,6 +48,7 @@ export class ThemeManager implements IThemeManager { this._settings = settings; this._settings.changed.connect(this._loadSettings, this); this._loadSettings(); + this._initOverrideProps(); }); } @@ -70,6 +73,19 @@ export class ThemeManager implements IThemeManager { return this._themeChanged; } + /** + * Get the value of a CSS variable from its key. + * + * @param key - A Jupyterlab CSS variable, without the leading '--jp-'. + * + * @return value - The current value of the Jupyterlab CSS variable + */ + getCSS(key: string): string { + return getComputedStyle(document.documentElement).getPropertyValue( + `--jp-${key}` + ); + } + /** * Load a theme CSS file by path. * @@ -98,6 +114,62 @@ export class ThemeManager implements IThemeManager { }); } + /** + * Loads all current CSS overrides from settings. If an override has been + * removed or is invalid, this function unloads it instead. + */ + loadCSSOverrides(): void { + const newOverrides = + (this._settings.user['overrides'] as Dict) || {}; + + // 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 + 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: string, val: string): boolean { + // 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. * @@ -120,6 +192,14 @@ export class ThemeManager implements IThemeManager { }); } + /** + * Add a CSS override to the settings. + */ + setCSSOverride(key: string, value: string): Promise { + this._overrides[key] = value; + return this._settings.set('overrides', this._overrides); + } + /** * Set the current theme. */ @@ -134,6 +214,26 @@ export class ThemeManager implements IThemeManager { 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: string): Promise { + 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: string): Promise { + return this._incrFontSize(key, false); + } + /** * Test whether a given theme styles scrollbars, * and if the user has scrollbar styling enabled. @@ -145,6 +245,51 @@ export class ThemeManager implements IThemeManager { ); } + /** + * Test if the user has scrollbar styling enabled. + */ + isToggledThemeScrollbars(): boolean { + return !!this._settings.composite['theme-scrollbars']; + } + + /** + * Toggle the `theme-scrollbbars` setting. + */ + toggleThemeScrollbars(): Promise { + return this._settings.set( + 'theme-scrollbars', + !this._settings.composite['theme-scrollbars'] + ); + } + + /** + * Change a font size by a positive or negative increment. + */ + private _incrFontSize(key: string, add: boolean = true): Promise { + // get the numeric and unit parts of the current font size + const parts = (this.getCSS(key) || '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 + */ + private _initOverrideProps(): void { + const oSchema = (this._settings.schema.definitions as any).cssOverrides + .properties; + + // the description field of each item in the overrides schema stores a + // CSS property that will be used to validate that override's values + Object.keys(oSchema).forEach(key => { + this._overrideProps[key] = oSchema[key].description; + }); + } + /** * Handle the current settings. */ @@ -278,6 +423,8 @@ export class ThemeManager implements IThemeManager { private _current: string | null = null; private _host: Widget; private _links: HTMLLinkElement[] = []; + private _overrides: Dict = {}; + private _overrideProps: Dict = {}; private _outstanding: Promise | null = null; private _pending = 0; private _requests: { [theme: string]: number } = {}; diff --git a/packages/statusbar/src/defaults/kernelStatus.tsx b/packages/statusbar/src/defaults/kernelStatus.tsx index 23a6f0f59b80..0d76fa145d87 100644 --- a/packages/statusbar/src/defaults/kernelStatus.tsx +++ b/packages/statusbar/src/defaults/kernelStatus.tsx @@ -181,12 +181,17 @@ export namespace KernelStatus { const oldState = this._getAllState(); const { newValue } = change; if (newValue !== null) { - newValue.getSpec().then(spec => { - // sync setting of status and display name - this._kernelStatus = newValue.status; - this._kernelName = spec.display_name; - this._triggerChange(oldState, this._getAllState()); - }); + newValue + .getSpec() + .then(spec => { + // sync setting of status and display name + this._kernelStatus = newValue.status; + this._kernelName = spec.display_name; + this._triggerChange(oldState, this._getAllState()); + }) + .catch(err => { + throw err; + }); } else { this._kernelStatus = 'unknown'; this._kernelName = 'unknown';