import Color from 'color'; import { unreachableCase } from 'ts-assert-unreachable'; import { getAppDefaultTheme } from '../common/constants'; import { render, THROW_ON_ERROR } from '../common/render'; import { ThemeSettings } from '../models/settings'; import type { Theme } from './index'; import { ColorScheme, getThemes } from './index'; interface ThemeBlock { background?: { default: string; success?: string; notice?: string; warning?: string; danger?: string; surprise?: string; info?: string; }; foreground?: { default: string; success?: string; notice?: string; warning?: string; danger?: string; surprise?: string; info?: string; }; highlight?: { default: string; xxs?: string; xs?: string; sm?: string; md?: string; lg?: string; xl?: string; }; } type ThemeInner = ThemeBlock & { rawCss?: string; styles?: | { dialog?: ThemeBlock; dialogFooter?: ThemeBlock; dialogHeader?: ThemeBlock; dropdown?: ThemeBlock; editor?: ThemeBlock; link?: ThemeBlock; overlay?: ThemeBlock; pane?: ThemeBlock; paneHeader?: ThemeBlock; sidebar?: ThemeBlock; sidebarHeader?: ThemeBlock; sidebarList?: ThemeBlock; tooltip?: ThemeBlock; transparentOverlay?: ThemeBlock; } | null; }; export interface PluginTheme { name: string; displayName: string; theme: ThemeInner; } export async function generateThemeCSS(theme: PluginTheme) { const renderedTheme: ThemeInner = await render( theme.theme, theme.theme, null, THROW_ON_ERROR, theme.name, ); const n = theme.name; let css = ''; // For the top-level variables, merge with the base theme to ensure that // we have everything we need. css += wrapStyles( n, '', getThemeBlockCSS({ ...renderedTheme, background: { ..._baseTheme.background, ...renderedTheme.background }, foreground: { ..._baseTheme.foreground, ...renderedTheme.foreground }, highlight: { ..._baseTheme.highlight, ...renderedTheme.highlight }, }), ); if (renderedTheme.styles) { const styles = renderedTheme.styles; // Dropdown Menus css += wrapStyles( n, '.theme--dropdown__menu', getThemeBlockCSS(styles.dropdown || styles.dialog), ); // Tooltips css += wrapStyles(n, '.theme--tooltip', getThemeBlockCSS(styles.tooltip || styles.dialog)); // Overlay css += wrapStyles( n, '.theme--transparent-overlay', getThemeBlockCSS(styles.transparentOverlay), ); // Dialogs css += wrapStyles(n, '.theme--dialog', getThemeBlockCSS(styles.dialog)); css += wrapStyles(n, '.theme--dialog__header', getThemeBlockCSS(styles.dialogHeader)); css += wrapStyles(n, '.theme--dialog__footer', getThemeBlockCSS(styles.dialogFooter)); // Panes css += wrapStyles(n, '.theme--pane', getThemeBlockCSS(styles.pane)); css += wrapStyles(n, '.theme--pane__header', getThemeBlockCSS(styles.paneHeader)); // @ts-expect-error -- TSCONVERSION css += wrapStyles(n, '.theme--app-header', getThemeBlockCSS(styles.appHeader)); // Sidebar Styles css += wrapStyles(n, '.theme--sidebar', getThemeBlockCSS(styles.sidebar)); css += wrapStyles(n, '.theme--sidebar__list', getThemeBlockCSS(styles.sidebarList)); css += wrapStyles(n, '.theme--sidebar__header', getThemeBlockCSS(styles.sidebarHeader)); // Link css += wrapStyles(n, '.theme--link', getThemeBlockCSS(styles.link)); // Code Editors css += wrapStyles(n, '.theme--editor', getThemeBlockCSS(styles.editor)); // HACK: Dialog styles for CodeMirror dialogs too css += wrapStyles(n, '.CodeMirror-info', getThemeBlockCSS(styles.dialog)); } return css; } function getThemeBlockCSS(block?: ThemeBlock) { if (!block) { return ''; } const indent = '\t'; let css = ''; const addColorVar = (variable: string, value?: string) => { if (!value) { return; } try { const parsedColor = Color(value); const rgb = parsedColor.rgb(); addVar(variable, rgb.string()); addVar(`${variable}-rgb`, rgb.array().join(', ')); } catch (err) { console.log('Failed to parse theme color', value); } }; const addVar = (variable: string, value?: string) => { if (!value) { return; } css += `${indent}--${variable}: ${value};\n`; }; const addComment = comment => { css += `${indent}/* ${comment} */\n`; }; const addNewLine = () => { css += '\n'; }; if (block.background) { const { background } = block; addComment('Background'); addColorVar('color-bg', background.default); addColorVar('color-success', background.success); addColorVar('color-notice', background.notice); addColorVar('color-warning', background.warning); addColorVar('color-danger', background.danger); addColorVar('color-surprise', background.surprise); addColorVar('color-info', background.info); addNewLine(); } if (block.foreground) { const { foreground } = block; addComment('Foreground'); addColorVar('color-font', foreground.default); addColorVar('color-font-success', foreground.success); addColorVar('color-font-notice', foreground.notice); addColorVar('color-font-warning', foreground.warning); addColorVar('color-font-danger', foreground.danger); addColorVar('color-font-surprise', foreground.surprise); addColorVar('color-font-info', foreground.info); addNewLine(); } if (block.highlight) { const { highlight } = block; addComment('Highlight'); addColorVar('hl', highlight.default); addColorVar('hl-xxs', highlight.xxs); addColorVar('hl-xs', highlight.xs); addColorVar('hl-sm', highlight.sm); addColorVar('hl-md', highlight.md); addColorVar('hl-lg', highlight.lg); addColorVar('hl-xl', highlight.xl); addNewLine(); } return css.replace(/\s+$/, ''); } function wrapStyles(theme: string, selector: string, styles: string) { if (!styles) { return ''; } return [ `[theme="${theme}"] ${selector}, `, `[subtheme="${theme}"] ${selector ? selector + '--sub' : ''} {`, styles, '}', '', '', ].join('\n'); } export function getColorScheme({ autoDetectColorScheme }: ThemeSettings): ColorScheme { if (!autoDetectColorScheme) { return 'default'; } if (window.matchMedia('(prefers-color-scheme: light)').matches) { return 'light'; } if (window.matchMedia('(prefers-color-scheme: dark)').matches) { return 'dark'; } return 'default'; } export async function applyColorScheme(settings: ThemeSettings) { const scheme = getColorScheme(settings); switch (scheme) { case 'light': await setTheme(settings.lightTheme); break; case 'dark': await setTheme(settings.darkTheme); break; case 'default': await setTheme(settings.theme); break; default: unreachableCase(scheme); } } export async function setTheme(themeName: string) { if (!document) { return; } const head = document.head; const body = document.body; if (!head || !body) { return; } const themes: Theme[] = await getThemes(); // If theme isn't installed for some reason, set to the default if (!themes.find(t => t.theme.name === themeName)) { console.log(`[theme] Theme not found ${themeName}`); themeName = getAppDefaultTheme(); } body.setAttribute('theme', themeName); for (const theme of themes) { let themeCSS = (await generateThemeCSS(theme.theme)) + '\n'; const { name } = theme.theme; const { rawCss } = theme.theme.theme; let s = document.querySelector(`style[data-theme-name="${name}"]`); if (!s) { s = document.createElement('style'); s.setAttribute('data-theme-name', name); head.appendChild(s); } if (typeof rawCss === 'string' && name === themeName) { themeCSS += '\n\n' + rawCss; } s.innerHTML = themeCSS; } } export async function setFont(settings: Record) { if (!document) { return; } const html = document.querySelector('html'); if (!html) { return; } html.style.setProperty('--font-default', settings.fontInterface); html.style.setProperty('--font-monospace', settings.fontMonospace); html.style.setProperty('--font-ligatures', settings.fontVariantLigatures ? 'normal' : 'none'); html.style.setProperty('font-size', `${settings.fontSize}px`); } const _baseTheme = { background: { default: '#fff', success: '#75ba24', notice: '#d8c84d', warning: '#ec8702', danger: '#e15251', surprise: '#6030BF', info: '#20aed9', }, foreground: { default: '#666', success: '#fff', notice: '#fff', warning: '#fff', danger: '#fff', surprise: '#fff', info: '#fff', }, highlight: { default: 'rgba(130, 130, 130, 1)', xxs: 'rgba(130, 130, 130, 0.05)', xs: 'rgba(130, 130, 130, 0.1)', sm: 'rgba(130, 130, 130, 0.25)', md: 'rgba(130, 130, 130, 0.35)', lg: 'rgba(130, 130, 130, 0.5)', xl: 'rgba(130, 130, 130, 0.8)', }, };