给桌面应用加暗色主题:全 UI 颜色 token 化 + 浅/深/跟随系统三态切换
"你们的 App 什么时候支持深色?"每次听到这种需求,我的第一反应都是:别急着加开关,先数一数代码里有多少处写死的颜色。
我把我自己维护的开源手绘白板 boundless 刚落地的暗色主题实现(src/theme.rs,一次提交迁移 182 处硬编码颜色)完整拆了一遍。
看完我可以负责任地告诉你三件事。
第一,暗色主题不是"再写一套样式",是"把颜色从代码里抽出来"——token 化做完了,深色模式就是多填一张色板的事。
第二,组件库的预设主题别直接用:它的长尾字段(你没碰过的组件颜色)留着,把语义 token 换成你的品牌色——这叫"品牌色板叠加组件库预设"。
第三,最坑的不是配色,是按钮:Button::primary 实际画在哪个 token 上,跟你想的可能完全不一样,不钉死的话深色模式下按钮会自己"反转"。
学习目标
读完这篇,你会给自己的桌面应用(思路框架无关,本文用 Rust + gpui 举例)加一套完整的暗色主题:浅色/深色/跟随系统三态切换、偏好持久化、一次切换全 UI 跟着变,外加三个真实踩坑。
1. 前置知识:语义 token
先给大家科普一个概念「语义 token」。
传统写法:background = rgb(0xfbfaf9)——颜色值写死在渲染代码里。加深色模式?182 处全找出来改一遍,漏一处就是一个"深色模式下的白块"。
语义 token 写法:background = theme.background——代码里只写"背景色"这个名字,"背景色在浅/深色模式下等于什么"由一张色板决定。翻译成人话:代码问"我要画什么",色板回答"用什么颜色画"。
token 化做完,加深色模式的工作量就从"改 182 处渲染代码"变成"填一张新色板"——这就是整个实现的核心。
2. 第一步:数清硬编码,全部换成语义 token
先量化:boundless 在 board.rs、ai/panel.rs、settings_page.rs 里搜 rgb( / rgba(,数出来 182 处,列清单逐处迁移——深色模式最怕漏网之鱼,发布后截图里一个白底输入框,比没有深色模式更丢人。
每一处都问:这个颜色在语义上是什么?背景、边框、次要文字、强调色……换成主题体系里的对应字段。rgba 半透明浮层也要走 token(“accent 色 × 透明度”,而不是写死一个半透明黑)——深色下半透明黑和半透明品牌色的观感差很多。
3. 第二步:品牌色板叠加组件库预设
最值得抄的一招。gpui-kit 自带浅/深两套预设 ThemeColor::light() / ThemeColor::dark(),但那是 shadcn 通用配色,跟 boundless 的品牌(暖纸白 / 暖墨黑)对不上。
fn brand_palette(mode: ThemeMode) -> ThemeColor {
let mut c = match mode { // 组件库预设打底:长尾字段全保留
ThemeMode::Light => *ThemeColor::light(),
ThemeMode::Dark => *ThemeColor::dark(),
};
match mode { // 只覆盖语义 token,换成品牌色
ThemeMode::Light => {
c.background = gpui::rgb(0xfbfaf9).into(); // 暖纸白
c.primary = gpui::rgb(0x1a5fd7).into(); // 品牌蓝
// ……
}
ThemeMode::Dark => {
c.background = gpui::rgb(0x1e1d1b).into(); // 暖墨黑
c.primary = gpui::rgb(0x3d7dd8).into(); // 深色下品牌蓝要提亮
}
}
c
}
翻译成人话:组件库预设负责"我没想到的角落",品牌色板负责"我在乎的语义颜色"。
注意深色配色不是简单反色:浅色品牌蓝 #1a5fd7 在深色背景上会发闷,要提亮到 #3d7dd8。每一对颜色都要在深色背景上实际看一眼,别信直觉。
应用主题就两行:Theme::change(mode, …) 加载该模式预设,再 Theme::update(cx, |theme| theme.colors = palette) 用品牌色板覆盖语义 token。
4. 第三步:三态偏好 + 持久化 + 跟随系统
pub enum ThemePref { Light, Dark, System }
偏好存 ~/.boundless/theme.json,启动时在第一个窗口渲染之前读出来应用——启动先白一下再切深色,那个闪白约等于没有深色模式。
跟随系统走 cx.window_appearance() 把 System 解析成 OS 当前外观。设置页加「外观」页,三选项即点即切:set_pref 先 save_pref 再 apply,点一下全 UI 当场变色。
5. 为什么这样设计:有些颜色刻意不跟主题
182 处迁完,boundless 刻意留了三处不跟主题:画布纸面、tooltip 浮层、GitHub logo——它们不是"界面 chrome",是"内容"或"品牌资产"。画布是用户画画的地方,就应该是纸的颜色;GitHub logo 是品牌标识,不该被主题染色。判断标准:跟主题的是"界面",不跟的是"内容和品牌"。token 化之前先分好类,能省一半返工。
6. 避坑清单
坑一:按钮画在哪个 token 上,跟你想的不一样。 最贵的坑。gpui-kit 里 Button::primary 实际画在 button_primary 等 token 上,而这几个在 shadcn 预设里深色是白钮、浅色是黑钮——切深色模式按钮直接"反转"成白色。解法:色板里显式把 button_primary 一家钉成品牌蓝(深色用提亮版)。
坑二:借用冲突。 builder 链里 cx.theme() 的借用和后面的可变借用(监听器、notify)打架,整个 Theme clone 又浪费。解法是一个 #[derive(Clone, Copy)] 的 Chrome 快照结构体,渲染前一次性把十几个颜色取出来,后面随便用。
坑三:别用纯黑 #000000。 暖墨黑 #1e1d1b 比纯黑柔和,边框、次要文字都带一点暖。纯黑 + 纯白看着"很深色",实际刺眼。
7. 总结
施工顺序:数清硬编码 → 全部语义 token 化 → 品牌色板叠加组件库预设 → 三态偏好持久化 → 跟随系统 → 设置页开关。token 化是 90% 的工作量,色板只是最后 10%。
给开发者的建议:
- 加深色模式前先 grep 数硬编码颜色,数量级决定工期——182 处就该是一次集中的迁移提交;
- 组件库预设的长尾字段全保留,只覆盖语义 token,升级组件库不返工;
- 按钮、输入框这类"换肤高危区",切完主题逐个截图检查,别信 token 名字;
- 先分清"界面"和"内容":画布、品牌 logo 刻意不跟主题是设计,不是偷懒;
- 偏好必须在首窗口渲染前应用,启动闪白约等于没有深色模式。
最后说一句:用户要的从来不是"深色模式"这个开关,要的是"深夜打开不晃眼"。token 化做扎实了,浅/深/跟随系统三态,就是一张色板的事。
项目开源地址:github.com/tsingliuwin/boundless(无界白板)。以上实现细节来自仓库真实代码(src/theme.rs,提交 1dec5c1),函数名与路径已在文中标注。



