兼容vscode插件的主题服务
作者: 发布于:

背景介绍

今年有幸参与了 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>name</key>
  <string>Comment</string>
  <key>scope</key>
  <string>comment</string>
  <key>settings</key>
  <dict>
    <key>foreground</key>
    <string>#75715E</string>
  </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 来获取对应的颜色键值。