在 Earendil,我们想打造尊重使用者选择的产品。因此,在更新 Pi 的主题时,我们希望有一款能适应其所运行终端的主题。大多数整天使用终端的人,都已经为它挑选了自己的主题。为什么不让 Pi 也反映这些选择呢?
结果便是新的系统主题,它现在是 Pi 的默认主题。它会向你的终端询问颜色,并据此构建 Pi 的主题。本文将介绍其工作原理。
终端中的一次 Pi 会话。启用 JavaScript 后,它会在此播放,并可展示于 24 种终端主题中。
在继续阅读之前,先剧透一下:一切都关乎对比度。终端 UI 的颜色选择是品味问题,而用户已经替我们回答了它。自此之后,所有其他决定都可以归结为一个问题:一种颜色相对于其背景有多显眼?如果我们答错了,就会给用户带来糟糕的体验。
但难点在于,对比度属于感知,无法轻易量化。两个人看着同一对颜色,可能会对它是否可读得出不同结论。我们做了一个可参与的交互式问卷,让你了解自己如何感知这种对比度,以及这与其他人的感知有何不同。本文末尾会介绍该问卷的细节。
你能相信 ANSI 调色板吗?
每个终端主题都会定义 16 种 ANSI 颜色,而最简单的终端匹配方式就是直接使用它们。但 ANSI 调色板实际上没有什么规则。原始标准 ECMA-48 于 1976 年通过;1979 年几乎完全相同的美国标准 ANSI X3.64 则是该名称的来源。标准命名了八种颜色(黑、红、绿、黄、蓝、洋红、青、白),却没有说明它们应当长什么样,也没有规定它们与背景的关系。
八种明亮变体并不属于标准。相当多的终端会用更亮的颜色渲染粗体文字,实际上因此多出了八种颜色。直接选择明亮颜色的代码后来由 IBM 的 aixterm 添加,并被 xterm 等终端采用。
既然明亮颜色起初源于粗体文字,我们或许会认为“明亮”意味着更突出。早期终端大多在深色屏幕上显示浅色文字,因此更亮也意味着更高的对比度。我们查看了 Ghostty 附带的 460 多个主题,发现如今这一规律只在一定程度上成立。深色主题中,约 60% 的明亮变体拥有更高对比度;浅色主题中则只有约四分之一,因为“明亮”通常仍代表更浅,而在浅色背景上反而意味着更低对比度。各主题本身也并不一致:在 Gruvbox Dark 中,亮蓝的对比度高于蓝色;在 Catppuccin Mocha 中则更低;在 Tokyo Night 中两者是同一种颜色。它们相对背景的对比度也差异很大。我们用 WCAG 2 对比度比率 进行测量:它比较两种颜色的亮度,范围从 1:1(无对比)到 21:1(黑配白)。普通文本至少应达到 4.5:1,大号文本和 UI 元素应达到 3:1。很多软件用于次要文本的亮黑色,在大多数深色主题中甚至达不到 3:1。
这并不是主题的缺陷。调色板本就是为简单应用程序的输出着色而设计的,例如红色错误信息或绿色成功信息;许多主题追求的是好看,而不是满足最低对比度。但这使得难以在其上构建更复杂、可访问的 TUI。Pi 大约有 60 个颜色角色,从正文和淡化文字,到工具调用背后的面板,再到红色错误面板上的红色文字。我们既想使用你的颜色,又要确保它们全都保持可读。
对比度就是一切
对比度是用户界面色彩中最重要的方面之一。对比度过低时,人们会难以使用你的产品。对比度主要由明度决定。饱和度会有少许影响,但最重要的因素仍然是颜色与其背后颜色相比有多亮或多暗。
通常书写颜色的 RGB 没有明度轴。它是为了让机器在显示器上显示颜色而设计的,不是为了让人类理解颜色。作为一个三维形状,它是规整的立方体,每个通道对应一条轴。但在这个立方体中相近的颜色,不一定是人类会认为相似的颜色。例如,#0000ff 和 #00ff00 都有一个通道为满强度,但在白色背景上,蓝色的 WCAG 对比度比率为 8.6:1,绿色却只有 1.4:1。
作为立方体的 RGB 色彩空间,每个通道对应一条轴:黑白位于相对的顶点,红、绿、蓝及其混色位于中间的各顶点。
RGB 色彩空间。拖动以旋转,选择一种颜色可在该处将其剖开。
OKLCH 等感知色彩空间则围绕人类感知构建。在 OKLCH 中相近的颜色,也是人类会认为相似的颜色;它的轴也正是人们描述颜色时所用的维度:明度、色度(颜色有多鲜艳)和色相。由于它遵循的是人类感知而非显示器硬件,其形状比立方体怪异得多。
作为景观的 OKLCH 中的相同颜色:明度从黑到白,色相从前到后,高度表示该明度和色相所能拥有的最大色度。每种色相都在不同明度处达到峰值:蓝色接近黑色,黄色接近白色。
OKLCH 中的相同颜色:明度从左至右,色相从前至后,色度作为高度。拖动以旋转,选择一种颜色可在该处将其剖开。
有了明度轴,系统主题背后的思路就很简单:Pi 根据对比度要求决定每种颜色的明度,并从你的终端调色板中取得色相和色度。
明度
为确定每种颜色所需的明度,我们记录了 UI 中两种颜色相接的每一处。每个面板与终端背景之间都需要足够的对比度,以便被识别为独立区域,但又不能高到让人分心。每种前景色都必须在它可能出现的每种背景上拥有足够的对比度。例如,一条错误信息必须能在背景、选中行和全部三个工具面板上读取。在代码中,这是一组规则:
const COLORS = ["accent", "success", "error", "warning"];
const SURFACES = ["background", "selectedBg", ...TOOL_PANELS];
{ token: "text", on: ["background"], level: "text" },
...each(COLORS, SURFACES, "readable"),
{ token: "dim", on: [...SURFACES, "customMessageBg"], level: "subtle" },
对比度算法通常接收两种颜色,并返回它们之间的对比度。这里我们需要反过来:我们知道背景和所需对比度,却需要求出颜色。我以前曾逆向过对比度算法,在 GitHub 上可以找到 WCAG 和感知对比度的实现。使用逆向算法后,主题计算会成为一个循环:从面板开始,Pi 计算每种颜色为满足其每条规则所需的明度,并采用要求最严格的那个。
算法
我们的第一个原型正是这样做的,并配有一个评审应用,让我们能调整最低对比度。该应用可以使用 Ghostty 附带的任意主题渲染 Pi,因此我们能检查一组差异巨大的主题样本,确保系统在默认主题以外同样可靠。
该原型使用了一个知名的感知对比度算法及其参考实现。我们随后用它测试了大量 Ghostty 主题,并确保它在所有主题中都表现良好。但我们不想把这个算法本身一起发布。我们尝试过更简单的度量,却无法逼近结果。最后,我们让一个编程 agent 来做拟合。对于参考实现中的每一个对比度等级,agent 都会在从白到黑的每一个灰色背景上运行原始算法,记录明度,并对结果拟合多项式。它最终确定了五次多项式,发现其与参考明度足够接近。如今 Pi 只内置这些系数。
下图展示了这些多项式:上文规则中的每个对比度等级各有一条曲线。横轴是绘制颜色的表面明度,纵轴是颜色在该表面上所需的明度。对角线表示完全没有对比度:颜色与其表面一样亮。竖线表示各个表面:背景和面板;Pi 先在背景上求解面板。规则曲线与某个表面相交的位置,便可读出该规则在此处所需的明度;要求最严格的那一项胜出。拖动背景,看看所有颜色如何随之变化。
Pi 对比度等级的曲线图:对于表面的每一个明度,颜色在其上所需的明度。深色表面上,曲线位于对角线上方,颜色比表面更亮;浅色表面上则位于下方。等级越强,曲线离对角线越远。
在图表上拖动,或使用滑块来改变背景明度,也可以选择一个终端主题。选择一条规则或终端中的一个单词,即可查看它在每个表面上所需的明度。点击颜色可在其他图中显示它。
我们首先将查询到的终端颜色从 RGB 转换为 OKLCH 和 OKHSL。由此得到原始明度 L、色度 C、色相 H,以及相对于 sRGB 在该明度下可显示范围的饱和度 S。接着,以背景的明度计算多项式。得到的明度并不直接替换,而是转换为 OKHSL 明度,并使用原始色相和调整后的饱和度计算新颜色。这里会应用一条钟形饱和度曲线:在中等明度最强,越接近黑白越弱。最后,将其转换为 OKLCH;Pi 会限制原始色度,使 H 保持不变,L 来自对比度规则,而 C 采用调整后 OKHSL 产生的值。这样做是为了避免例如淡粉色在移向较深色调时变得过于鲜艳。该调整确保其永远不会比原始颜色更鲜艳。
色相与色度
Pi 保持你的调色板颜色的原始色相。色度则更棘手。OKLCH 的怪异形状显示了屏幕可以呈现多少色度,而这取决于色相和明度。在明度 0.9 时,屏幕可显示的最鲜艳黄色的色度约为 0.2,而最鲜艳蓝色只能达到约 0.05。因此,当 Pi 将调色板颜色移至所需明度时,该色度可能在此明度下并不存在;将它映射回可显示颜色时,也可能改变 Pi 刚刚计算出的明度。
这正是 Pi 在 OKHSL 中构建颜色的原因。OKHSL 建立在与 OKLCH 相同的基础上,使用相同色相,但将怪异形状重新拉伸成圆柱体。它的饱和度从 0% 到 100%;100% 始终表示“该色相在此明度下所能达到的最鲜艳程度”。它试图兼得两者之长:尽可能贴近人类感知,同时恢复让基于 RGB 的色彩空间易于使用的简单几何结构。每一种色相、饱和度和明度组合,都是你的屏幕能够显示的颜色,因此它非常适合用于生成主题和调色板。Pi 也会让饱和度在接近黑白时衰减,使非常暗或非常亮的颜色——例如仅比背景略亮的面板——带有一丝颜色,而不是成为明亮色块。
但保持相同饱和度,并不意味着颜色同样鲜艳。因为饱和度相对于屏幕可显示范围而言,在不同明度下,相同比例可能代表非常不同的色度。发布不久后,一份 bug 报告 指出,Pi 在 Catppuccin Frappé 中看起来比终端鲜艳得多。Catppuccin 的粉色 #f4b8e4 的 OKHSL 饱和度为 84%,但这是相对于屏幕在如此高明度下只能显示的少量色度的 84%。Pi 的强调色为保持可读性需要更暗;而此处形状宽得多,84% 饱和度就变成了 #eb76d1,其色度约为原始粉色的两倍。修复方式是同时限制色度:调色板颜色可以移动到不同明度,但永远不能比你调色板中的原色更鲜艳。加入上限后,强调色变为 #cc92bd,看起来又像 Catppuccin 了。
因此,调色板颜色的色度最终会被限制三次:通过 OKHSL 受屏幕可显示范围限制;通过向黑白方向的衰减受限;以及受调色板原有色度限制。
下方两种视图将色彩空间与 Pi 在其中的操作分开。OKHSL 的圆柱保持固定。Pi 的范围则取决于源颜色和 UI 角色族:源颜色的饱和度适用于其自身明度,衰减也以该点为参照。向中间移动时,饱和度永远不会超过源颜色。不同族使用不同的衰减曲线;黄色警告与紫色强调色不会使用同一条曲线。
OKHSL 色彩空间
完整的 OKHSL 圆柱:底部是黑色、顶部是白色的明度,饱和度从中心的灰色向外延伸,色相环绕四周。它在源色相处被剖开。
Pi 如何从调色板颜色生成结果
终端主题某个 ANSI 颜色及使用它的角色所对应的 Pi 输出范围,是圆柱轮廓内一片着色的色相切片。其外边缘遵循锚定的饱和度衰减与源色度上限,并在源颜色自己的明度处穿过源颜色本身。其内部展示较低饱和度设置下的输出。没有终端调色板时,Pi 会采用该角色族自身的色相和饱和度曲线。
选择一种颜色,即可在此处或上方终端中查看 Pi 能否从源颜色生成它。圆点是源颜色;方块是 Pi 在此主题中使用的颜色。拖动任一视图会同时旋转两者。
结果
Pi 在启动时向终端询问前景、背景和 ANSI 颜色,并在终端于浅色与深色之间切换时重建主题。如果终端只报告背景,Pi 会使用自己的色相;如果终端什么也不报告,它就回退到 ANSI 颜色,并让终端绘制它们。生成的颜色会保留终端的色相,但明度会被调整为相近的对比度。Gruvbox 的深红色会变亮很多,Catppuccin Latte 的红色会稍微变暗,而 Nord 的颜色几乎不动。
如果你尚未选择主题,你已经在使用系统主题。否则,可以在 /settings 的 Theme 中切换到它。如果你的终端主题在 Pi 中看起来不对,请带上主题名称提交 issue。
问卷
本文所有与对比度相关的数字,都是对平均人群如何感知对比度的估计,其依据是既有的对比度感知算法。但还没有真正能说明全貌的开放数据集。我们正在开发自己的开源对比度算法,因此非常希望了解真实的人如何在真实屏幕上感知对比度。
问卷约需 3 分钟完成,结束时我们会展示你的答案与其他参与者答案的对比。
我们很希望你参与。问卷地址为 contrastsurvey.earendil.com。如果你参与,我们会用你的答案来训练对比度算法。