Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Theia 技术揭秘之 Theme #87

Open
Pines-Cheng opened this issue Jan 19, 2021 · 0 comments
Open

Theia 技术揭秘之 Theme #87

Pines-Cheng opened this issue Jan 19, 2021 · 0 comments
Labels

Comments

@Pines-Cheng
Copy link
Owner

Pines-Cheng commented Jan 19, 2021

副本_副本_科技工作者复工科技风公众号首图

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);
    }
}

参考

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant