乐趣区

关于javascript:Theia-技术揭秘之-Theme

  • 遵循了 VSCode 的 Theme 标准,可参考:Color Theme
  • Color Theme 包含 Workbench colors 和 Syntax colors
  • 语法高亮(Syntax Highlight)由两局部组成:Tokenization 和 Theming
  • 基于 CSS Custom Properties 实现主题切换
  • 通过插件加载 / 切换主题的形式值得借鉴
  • 语法高亮基于:vscode-textmate 和 vscode-oniguruma

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

从 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-oniguruma。vscode-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);
    }
}

参考

  • VSCode Extension API – Color Theme
  • vscode-theme-defaults
  • WebIDE 的开发记录其五
退出移动版