- 遵循了 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
自定义主题
语法高亮
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);
}
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。