背景介绍
今年有幸参与了 IDE 共建项目组,负责主题服务的设计实现。说到主题服务,我们可能立马会想到 VSCODE 丰富的主题生态。VSCODE 有着庞大的插件市场,主题插件是其中非常重要的一部分。利用主题插件,我们可以对 IDE 的各个部分进行颜色定制( 颜色键值列表),让 IDE 呈现各种视觉效果,满足用户个性化的需求。
我们要建设的 IDE(下文简称开天 IDE)拥有和 VSCODE 类似的布局及组件,因而基本可以完整复用 VSCODE 定义的颜色键值。开天 IDE 与 VSCODE 的整体结构对比如下图:
图 VSCODE布局对比开天 IDE 布局
除了 IDE 的各个功能组件之外,一个 IDE 的核心能力是编辑器,实现上,我们选用了 VSCODE 的内置编辑器 MONACO;由于 MONACO 是由 VSCODE 项目构建出来的独立编辑器,所以经过一些简单的规则转换,VSCODE 的主题可以直接应用到 MONACO 上。
图 VSCODE主题信息(左) 对比 MONACO主题信息(右)
下面是我们最终实现的主题插件的兼容效果:
图 当前开发 DEMO 演示
VSCODE 的主题服务
VSCODE 的主题服务提供了组件区域的 前景色、背景色 替换能力以及编辑器的 TOKEN 颜色、字体样式 定义能力。通过简单的配置,一个主题插件几乎可以对任何 VSCODE 的 UI 组件做样式上的定制。要开发一个主题插件,我们需要实现 themes 或 colors 贡献点。
colors 贡献点,会贡献一个新的色值或覆盖一个已有色值
"contributes": {
"colors": [{
"id": "superstatus.error",
"description": "Color for error message in the status bar.",
"defaults": {
"dark": "errorForeground",
"light": "errorForeground",
"highContrast": "#010203"}}]}
themes 贡献点,会贡献一个新的主题
"contributes": {
"themes": [{
"label": "Monokai",
"uiTheme": "vs-dark",
"path": "./themes/Monokai.tmTheme"}]}
下面我们从最基础的 colors contribution 讲起,看看 VSCODE 是怎么做主题服务的。
colors contribution
要实现一个色值的贡献点,首先我们需要一个 ColorRegistry,来维护颜色 ID 与其色值的数据关系。其次我们需要一个色值到实际样式的实现方案,来将数据渲染到视图层。
图 VSCODE color contribution类图
这里的样式应用实现方案分为两种,分别应对静态的样式声明场景及动态的色值使用场景。
对于静态的样式声明场景,VSCODE 的主题服务定义了一个 IThemeParticipant的概念,类型定义如下:
export interface ICssStyleCollector {
addRule(rule: string): void;
}
export interface IThemingParticipant {
(theme: ITheme, collector: ICssStyleCollector, environment: IEnvironmentService): void;
}
主题服务的使用方向主题服务注册一个 participant,该 participant 会描述当前使用方如何将维护在 Theme 内的色值转换成 css 规则:
registerThemingParticipant((theme, collector) => {
const lineHighlight = theme.getColor(editorLineHighlight);
if (lineHighlight) {
collector.addRule(`.MONACO-editor .margin-view-overlays .current-line-margin { background-color: ${lineHighlight}; border: none; }`);
}
});
VSCODE 主题服务会遍历所有注册的 ThemeParticipant,并收集所有与主题相关的 css 规则,并 append 到 html 的头部。我们调用 monaco.setTheme
设置主题时 participant 就会重新做一次生成。
对于动态的使用场景,只需要从 ColorRegistry 内根据颜色 ID 取色值就可以了。为了便于 widget 的使用,VSCODE 定义了一个 Themable 的基类,在内部做了颜色的 filter 及主题切换逻辑处理。
图 Themable 基类
基于上述场景,VSCODE 从插件的色值数据到最终渲染数据的整体流程为:
图 VSCODE color contribution声明到应用
themes contribution
colorRegistry 的实现是主题服务的基础,themes contribution 只是在 colorRegistry 基础之上的应用层的封装,主要处理了主题维度的颜色数据收集与管理。
回到刚刚的 VSCODE 主题插件信息,一个主题主要包含两个部分,一部分是 tokenColors,用于 MONACO 的主题样式定义;一部分是 colors,与 color 贡献点的作用一致,主要用于对 IDE 的 UI 控件进行色值的定义。
theme 的 colors 部分配置可视为 color 批量注册的一个方式,我们就不再赘述,主要看一下 tokenColors 应用到 MONACO 编辑器这一步的设计和实现。
我们先看一下 MONACO 文档的主题类型定义:
export interface IStandaloneThemeData {
base: BuiltinTheme;
inherit: boolean;
rules: ITokenThemeRule[];
encodedTokensColors?: string[];
colors: IColors;
}
其中 base 属性定义了 MONACO 的主题的基础类型,总共包含三个值:'vs' | 'vs-dark' | 'hc-black'
,分别对应亮色主题、暗色主题和高对比主题。inherit
决定当前主题是否继承一个已有的基础主题。colors
与上述的 colors 类似,主要是定义 MONACO 内置组件(比如搜索框、快速打开栏)的颜色,类型定义为{ [colorId: string]: string; }。rules
定义了 token 及对应的色值或字体样式,如
{
foreground:”D4D4D4”,
token:”meta.embedded”
}
至于 encodedTokensColors 的作用,是将 tokenize 后的 token 快速映射到目标的色值,参见 VSCODE 源码 vs/editor/common/modes/supports/tokenization.ts/TokenTheme.createFromRawTokenTheme
。
再看一下 VSCODE 的 tokensColor 的两种定义方式:
JSON定义方式
{
tokenColors: [{
"name": "coloring of the Java import and package identifiers",
"scope": ["storage.modifier.import.java","variable.language.wildcard.java","storage.modifier.package.java"
],
"settings": {
"foreground": "#d4d4d4"}}]
}
textmate主题,plist定义方式
<dict>
<key>namekey>
<string>Commentstring>
<key>scopekey>
<string>commentstring>
<key>settingskey>
<dict>
<key>foregroundkey>
<string>#75715Estring>
dict>
dict>
所以我们只要将 VSCODE 的 scope + settings 的主题规则拍平一下,变成下面这种数据格式,就可以无缝应用到我们的 MONACO 当中了。
rules: [
{ token: 'comment', foreground: 'ffa500', fontStyle: 'italic underline' },
{ token: 'comment.js', foreground: '008800', fontStyle: 'bold' },
{ token: 'comment.css', foreground: '0000ff' }
]
关于颜色如何应用到语法解析后对应的 token 上,后面会有 language 专门的文章来说明。
开天 IDE 的设计与实现
开天 IDE 的主题服务实现整体上与 VSCODE 的方案差异不大,只是在样式渲染实现上,静态的样式应用方法由晦涩的 ThemeParticipant + CssCollector 的方式改为直接使用 css 变量 的形式。如颜色 key input.background
会被转成 css 变量 --input-background
插入到页面的 head 中,各个组件的开发者只需要在 css 里使用该 css 变量即可,无需关心主题服务的存在,且不需要感知主题的切换逻辑,降低了主题的兼容成本。
下面是开天 IDE 的主题服务的简易时序图:
Material Theme 是如何应用到 IDE 的
下面以社区最热门的 VSCODE 主题插件 Material Theme 为例,介绍一下主题插件在开天 IDE 中是如何运行起来的。
IDE 前台启动后,会去自动读取插件在 package.json
里声明的 colors 贡献点和 themes 贡献点。对于 colors 贡献点,ThemeService 会自动将其注册到全局单例的 colorRegistry 内。对于 themes 贡献点,一个如下形式的主题信息将会被加载到内存中:
// Material Theme package.json
"name": "vsc-material-theme",
"themes": [
{
"label": "Material Theme",
"path": "./out/themes/Material-Theme-Default.json",
"uiTheme": "vs-dark"},
{
"label": "Material Theme High Contrast",
"path": "./out/themes/Material-Theme-Default-High-Contrast.json",
"uiTheme": "vs-dark"},
...
]
注册进来的每个主题都会根据主题的其余信息生成一个唯一的 ID,如 Material Theme 会生成主题ID: vs-dark vsc-material-theme-out-themes-material_theme_default-json
。此时 IDE 并不会去读取主题的实际内容。待应用执行到一个 onStart 生命周期时,ThemeService 会去尝试异步调用文件服务读取主题文件信息。假设我们上次使用的是 Material Theme 这个主题(主题状态存储恢复),或我们要手动切换到该主题,那么接下来这份 json 格式的主题文件会被我们读取为一个 ThemeData 对象:
export interface IThemeData {
id: string;
name: string;
colors: IColors;
encodedTokensColors: string[];
rules: ITokenThemeRule[];
base: BuiltinTheme;
inherit: boolean;
initializeFromData(data): void;
initializeThemeData(id, name, themeLocation: string): Promise<void>;
}
图 Meterial Theme 主题配置加载到 ThemeData
上文已经介绍了 VSCODE 主题信息要应用到 MONACO 上需要做的转换处理,我们直接看一段简单的代码就可以理解转换的逻辑:
// colors部分从hex色值转成内置的color对象
for (let colorId in colors) {
const colorHex = colors[colorId];
if (typeof colorHex === 'string') { // ignore colors tht are null
resultColors[colorId] = Color.fromHex(colors[colorId]);
}
}
// tokenColors部分做展开
const settings = Object.keys(tokenColor.settings).reduce((previous: { [key: string]: string }, current) => {
let value: string = tokenColor.settings[current];
if (typeof value === typeof '') {
value = value.replace(/^\#/, '').slice(0, 6);
}
previous[current] = value;
return previous;
}, {});
this.rules.push({
...settings, token: scope,
});
主题信息加载后,colors 部分会在应用启动或主题切换时转成 css 变量 append 到页面内,转换规则为:list.hoveBackground -> --list-hoverBackground
,相关组件直接在 css 内使用该变量即可。ThemeData 对象也会直接通过 monaco.defineTheme
接口,将 tokenColors 及 MONACO 相关 UI 组件的颜色应用上去。
最后就大功告成啦!
图 Material Theme HC 效果图
IDE 插件如何接入主题
如果你想开发的是一个通过 Webview 来实现的插件,那么你只需要在页面内使用 VSCODE Theme Color 内颜色键值对应的 css 变量即可。如果你需要在插件逻辑内使用主题色值的话,可以直接通过 getColor api 来获取对应的颜色键值。