1
头图

Theming

在 Visual Studio Code 中,主题有三种类型:

  • Color Theme: 从 UI Component Identifier 和 Text Token Identifier 的颜色映射
  • File Icon Theme:从文件类型/文件名到图像的映射
  • Product Icon Theme:UI 的 icon,包括 Side bar, Activity bar, status bar 到 editor glyph margin

image

从 Contributes Theme 配置来看:

  • colors 控制 UI 组件的颜色
  • tokenColors 定于 editor 的代码高亮样式,具体可查看:Syntax Highlight Guide
  • semanticTokenColors 作为 semanticHighlighting 的设置,允许增强编辑器中的高亮显示,具体可参考:Semantic Highlight Guide

自定义主题

可以参考:Create a new Color Theme

语法高亮

moanco-editor 和 VSCode 的高亮不太一样,比较简陋很不舒服,一番搜索发现 monaco-editor 的语言支持使用的是内置的 Monarch 这个语法高亮支持。

官方的解释 Why doesn't the editor support TextMate grammars?

主要就是因为 Textmate 语法解析依赖的 Oniguruma 是一个 C 语言下的解析功能,VSCode 可以使用 node 环境来调用原生的模块,但是在 web 环境下无法实现,即使通过 asm.js 转换后,性能依然会有 100-1000 倍的损失(16年9月的说明),而且 IE 不支持。

后来出现了 WebAssembly,于是就有了 vscode-onigurumavscode-oniguruma 就是 Oniguruma 的 WASM 编译版,以便于在浏览器环境运行。

monaco-editor 语言的支持也只有通过 worker 的 js ts html css json 这些。但是业内更通用、生态更丰富的是 Textmate,包括 VSCode 也是用的 Textmate。

Theia 将 monaco-editor, vscode-oniguruma and vscode-textmate 整合到一起,在 Editor 中获取 TM grammar 支持。

默认配置

在 Theia 应用入口的 package.json 文件的 "theia": { }字段中配置。如:examples/browser/package.json

// dev-packages/application-package/src/application-props.ts
    export const DEFAULT: ApplicationProps = {
        ...NpmRegistryProps.DEFAULT,
        target: 'browser',
        backend: {
            config: {}
        },
        frontend: {
            config: {
                applicationName: 'Eclipse Theia',
                defaultTheme: 'dark',
                defaultIconTheme: 'none'
            }
        },
        generator: {
            config: {
                preloadTemplate: ''
            }
        }
    };

然后在 Theming 中有 defaultTheme :

    /**
     * The default theme. If that is not applicable, returns with the fallback theme.
     */
    get defaultTheme(): Theme {
        return this.themes[FrontendApplicationConfigProvider.get().defaultTheme] || this.themes[ApplicationProps.DEFAULT.frontend.config.defaultTheme];
    }

通过 API 设置主题

可以在插件中通过 VSCode API 设置主题。

获取 settings 配置,并更新 workbench.colorTheme 字段。

export async function start(context: theia.PluginContext): Promise<void> {
  const configuration = theia.workspace.getConfiguration()
  configuration.update('workbench.colorTheme', 'tide-dark-blue')
}

源码

Contributes 配置

PluginContributionHandler 负责插件 Contributes 配置的处理。

// packages/plugin-ext/src/main/browser/plugin-contribution-handler.ts
@injectable()
export class PluginContributionHandler {
    @inject(MonacoThemingService)
    protected readonly monacoThemingService: MonacoThemingService;

    @inject(ColorRegistry)
    protected readonly colors: ColorRegistry;

    @inject(PluginIconThemeService)
    protected readonly iconThemeService: PluginIconThemeService;

    /**
     * Always synchronous in order to simplify handling disconnections.
     * @throws never, loading of each contribution should handle errors
     * in order to avoid preventing loading of other contributions or extensions
     */
    handleContributions(clientId: string, plugin: DeployedPlugin): Disposable {
        if (contributions.themes && contributions.themes.length) {
            const pending = {};
            for (const theme of contributions.themes) {
                pushContribution(`themes.${theme.uri}`, () => this.monacoThemingService.register(theme, pending));
            }
        }

        if (contributions.iconThemes && contributions.iconThemes.length) {
            for (const iconTheme of contributions.iconThemes) {
                pushContribution(`iconThemes.${iconTheme.uri}`, () => this.iconThemeService.register(iconTheme, plugin));
            }
        }

        if (contributions.colors) {
            pushContribution('colors', () => this.colors.register(...contributions.colors));
        }
    }
}

MonacoThemingService 处理

MonacoThemingService.loadTheme 读取 json 文件并解析。

MonacoThemingService.register 注册主题。

// packages/monaco/src/browser/monaco-theming-service.ts
@injectable()
export class MonacoThemingService {
    protected async doRegister(theme: MonacoTheme,
        pending: { [uri: string]: Promise<any> },
        toDispose: DisposableCollection
    ): Promise<void> {
        try {
            const includes = {};
            const json = await this.loadTheme(theme.uri, includes, pending, toDispose);
            if (toDispose.disposed) {
                return;
            }
            const label = theme.label || new URI(theme.uri).path.base;
            const { id, description, uiTheme } = theme;
            toDispose.push(MonacoThemingService.register({ id, label, description, uiTheme: uiTheme, json, includes }));
        } catch (e) {
            console.error('Failed to load theme from ' + theme.uri, e);
        }
    }

    protected async loadTheme(
        uri: string,
        includes: { [include: string]: any },
        pending: { [uri: string]: Promise<any> },
        toDispose: DisposableCollection
    ): Promise<any> {
        const result = await this.fileService.read(new URI(uri));
        const content = result.value;
        if (toDispose.disposed) {
            return;
        }
        const themeUri = new URI(uri);
        if (themeUri.path.ext !== '.json') {
            const value = plistparser.parse(content);
            if (value && 'settings' in value && Array.isArray(value.settings)) {
                return { tokenColors: value.settings };
            }
            throw new Error(`Problem parsing tmTheme file: ${uri}. 'settings' is not array.`);
        }
        const json = jsoncparser.parse(content, undefined, { disallowComments: false });
        if ('tokenColors' in json && typeof json.tokenColors === 'string') {
            const value = await this.doLoadTheme(themeUri, json.tokenColors, includes, pending, toDispose);
            if (toDispose.disposed) {
                return;
            }
            json.tokenColors = value.tokenColors;
        }
        if (json.include) {
            includes[json.include] = await this.doLoadTheme(themeUri, json.include, includes, pending, toDispose);
            if (toDispose.disposed) {
                return;
            }
        }
        return json;
    }

    static register(theme: MonacoThemeJson): Disposable {
        const uiTheme = theme.uiTheme || 'vs-dark';
        const { label, description, json, includes } = theme;
        const id = theme.id || label;
        const cssSelector = MonacoThemingService.toCssSelector(id);
        const data = MonacoThemeRegistry.SINGLETON.register(json, includes, cssSelector, uiTheme);  // 注册  MonacoTheme
        return MonacoThemingService.doRegister({ id, label, description, uiTheme, data });
    }

    protected static doRegister(state: MonacoThemeState): Disposable {
        const { id, label, description, uiTheme, data } = state;
        const type = uiTheme === 'vs' ? 'light' : uiTheme === 'vs-dark' ? 'dark' : 'hc';
        const builtInTheme = uiTheme === 'vs' ? BuiltinThemeProvider.lightCss : BuiltinThemeProvider.darkCss;
        return new DisposableCollection(
            ThemeService.get().register({  // 注册 uiTheme
                type,
                id,
                label,
                description: description,
                editorTheme: data.name!,
                activate(): void {
                    builtInTheme.use();
                },
                deactivate(): void {
                    builtInTheme.unuse();
                }
            }),
            putTheme(state)
        );
    }
}

MonacoThemeRegistry Monaco主题注册

MonacoThemeRegistry 将主题与 monaco 关联,基于 vscode-textmate 实现 Editor 代码高亮。

通过 vscode-textmate 的 Registry 获取 encodedTokensColors。

monaco 是全局引入的。

// packages/monaco/src/browser/textmate/monaco-theme-registry.ts
import { IRawTheme, Registry, IRawThemeSetting } from 'vscode-textmate';

@injectable()
export class MonacoThemeRegistry {
    /**
     * Register VS Code compatible themes
     */
    register(json: any, includes?: { [includePath: string]: any }, givenName?: string, monacoBase?: monaco.editor.BuiltinTheme): ThemeMix {
        const name = givenName || json.name!;
        const result: ThemeMix = {
            name,
            base: monacoBase || 'vs',
            inherit: true,
            colors: {},
            rules: [],
            settings: []
        };
        if (typeof json.include !== 'undefined') {
            if (!includes || !includes[json.include]) {
                console.error(`Couldn't resolve includes theme ${json.include}.`);
            } else {
                const parentTheme = this.register(includes[json.include], includes);
                Object.assign(result.colors, parentTheme.colors);
                result.rules.push(...parentTheme.rules);
                result.settings.push(...parentTheme.settings);
            }
        }
        const tokenColors: Array<IRawThemeSetting> = json.tokenColors;
        if (Array.isArray(tokenColors)) {
            for (const tokenColor of tokenColors) {
                if (tokenColor.scope && tokenColor.settings) {
                    result.settings.push({
                        scope: tokenColor.scope,
                        settings: {
                            foreground: this.normalizeColor(tokenColor.settings.foreground),
                            background: this.normalizeColor(tokenColor.settings.background),
                            fontStyle: tokenColor.settings.fontStyle
                        }
                    });
                }
            }
        }
        // colors 处理
        if (json.colors) {
            Object.assign(result.colors, json.colors);
            result.encodedTokensColors = Object.keys(result.colors).map(key => result.colors[key]);
        }
        if (monacoBase && givenName) {
            for (const setting of result.settings) {
                this.transform(setting, rule => result.rules.push(rule));
            }

            // the default rule (scope empty) is always the first rule. Ignore all other default rules.
            const defaultTheme = monaco.services.StaticServices.standaloneThemeService.get()._knownThemes.get(result.base)!;
            const foreground = result.colors['editor.foreground'] || defaultTheme.getColor('editor.foreground');
            const background = result.colors['editor.background'] || defaultTheme.getColor('editor.background');
            result.settings.unshift({
                settings: {
                    foreground: this.normalizeColor(foreground),
                    background: this.normalizeColor(background)
                }
            });

            const reg = new Registry();
            reg.setTheme(result);
            // 获取 encodedTokensColors
            result.encodedTokensColors = reg.getColorMap();
            // index 0 has to be set to null as it is 'undefined' by default, but monaco code expects it to be null
            // eslint-disable-next-line no-null/no-null
            result.encodedTokensColors[0] = null!;
            this.setTheme(givenName, result);
        }
        return result;
    }

    setTheme(name: string, data: ThemeMix): void {
        // monaco auto refreshes a theme with new data
        monaco.editor.defineTheme(name, data);
    }
}

ThemeService UI 主题注册

挂载在全局对象中。

// packages/core/src/browser/theming.ts
export class ThemeService {

    private themes: { [id: string]: Theme } = {};
    private activeTheme: Theme | undefined;
    private readonly themeChange = new Emitter<ThemeChangeEvent>();

    readonly onThemeChange: Event<ThemeChangeEvent> = this.themeChange.event;  // onThemeChange 事件

    static get(): ThemeService {
        const global = window as any; // eslint-disable-line @typescript-eslint/no-explicit-any
        return global[ThemeServiceSymbol] || new ThemeService();
    }

    register(...themes: Theme[]): Disposable {
        for (const theme of themes) {
            this.themes[theme.id] = theme;
        }
        this.validateActiveTheme();
        return Disposable.create(() => {
            for (const theme of themes) {
                delete this.themes[theme.id];
            }
            this.validateActiveTheme();
        });
    }

    /**
     * The default theme. If that is not applicable, returns with the fallback theme.
     */
    get defaultTheme(): Theme {
        return this.themes[FrontendApplicationConfigProvider.get().defaultTheme] || this.themes[ApplicationProps.DEFAULT.frontend.config.defaultTheme];
    }
}

ColorContribution

其他地方可以通过继承 ColorContribution 接口实现 registerColors 方法来注册主题颜色的配置。

export const ColorContribution = Symbol('ColorContribution');
export interface ColorContribution {
    registerColors(colors: ColorRegistry): void;
}

如 TerminalFrontendContribution 的 terminal.background 实现:

// packages/terminal/src/browser/terminal-frontend-contribution.ts
registerColors(colors: ColorRegistry): void {
        colors.register({
            id: 'terminal.background',
            defaults: {
                dark: 'panel.background',
                light: 'panel.background',
                hc: 'panel.background'
            },
            description: 'The background color of the terminal, this allows coloring the terminal differently to the panel.'
        });
}

ColorRegistry

注册主题颜色的配置。

/**
 * It should be implemented by an extension, e.g. by the monaco extension.
 */
@injectable()
export class ColorRegistry {

    protected readonly onDidChangeEmitter = new Emitter<void>();
    readonly onDidChange = this.onDidChangeEmitter.event;
    protected fireDidChange(): void {
        this.onDidChangeEmitter.fire(undefined);
    }

    *getColors(): IterableIterator<string> { }

    getCurrentCssVariable(id: string): ColorCssVariable | undefined {
        const value = this.getCurrentColor(id);
        if (!value) {
            return undefined;
        }
        const name = this.toCssVariableName(id);
        return { name, value };
    }

    toCssVariableName(id: string, prefix = 'theia'): string { // 将配置转换为 CSS Custom Property
        return `--${prefix}-${id.replace(/\./g, '-')}`;
    }

    getCurrentColor(id: string): string | undefined {
        return undefined;
    }

    register(...definitions: ColorDefinition[]): Disposable {
        const result = new DisposableCollection(...definitions.map(definition => this.doRegister(definition)));
        this.fireDidChange();
        return result;
    }

    protected doRegister(definition: ColorDefinition): Disposable {
        return Disposable.NULL;
    }

}

ColorApplicationContribution

在 onStart 生命周期中,依次调用 registerColors 方法注册。

在切换主题时,通过 documentElement.style.setProperty(name, value) 依次设置 CSS Custom Property,从而变换主题。

// packages/core/src/browser/color-application-contribution.ts
@injectable()
export class ColorApplicationContribution implements FrontendApplicationContribution {

    protected readonly onDidChangeEmitter = new Emitter<void>();
    readonly onDidChange = this.onDidChangeEmitter.event;

    @inject(ColorRegistry)
    protected readonly colors: ColorRegistry;

    @inject(ContributionProvider) @named(ColorContribution)
    protected readonly colorContributions: ContributionProvider<ColorContribution>;

    private static themeBackgroundId = 'theme.background';

    onStart(): void {
        for (const contribution of this.colorContributions.getContributions()) {
            contribution.registerColors(this.colors);
        }

        this.updateThemeBackground();
        ThemeService.get().onThemeChange(() => this.updateThemeBackground());

        this.update();
        ThemeService.get().onThemeChange(() => this.update());
        this.colors.onDidChange(() => this.update());
    }

    protected update(): void {  // 更新主题
        if (!document) {
            return;
        }
        this.toUpdate.dispose();
        const theme = 'theia-' + ThemeService.get().getCurrentTheme().type;
        document.body.classList.add(theme);
        this.toUpdate.push(Disposable.create(() => document.body.classList.remove(theme)));

        const documentElement = document.documentElement;
        if (documentElement) {
            for (const id of this.colors.getColors()) {
                const variable = this.colors.getCurrentCssVariable(id);
                if (variable) {
                    const { name, value } = variable;
                    documentElement.style.setProperty(name, value);  // 依次设置 CSS Custom Property
                    this.toUpdate.push(Disposable.create(() => documentElement.style.removeProperty(name)));
                }
            }
        }
        this.onDidChangeEmitter.fire(undefined);
    }

    static initBackground(): void {
        const value = window.localStorage.getItem(this.themeBackgroundId) || '#1d1d1d';
        const documentElement = document.documentElement;
        documentElement.style.setProperty('--theia-editor-background', value);
    }
}

参考


Pines_Cheng
6.5k 声望1.2k 粉丝

不挑食的程序员,关注前端四化建设。