我做了个小项目叫caveman,是用 Lua 从零写的一套 Neovim 配色主题。整个主题的核心气质就藏在名字里:像穴居人一样原始、朴素,不要花哨的渐变色,不要刺眼的荧光蓝,只要一种长时间盯着屏幕写代码也不会觉得累的“洞穴感”。这篇文章我会把从设计思路、配色原理到完整实现、踩坑记录的全过程都拆出来,希望能给正在折腾自己的编辑器配色、或者想写一个属于自己主题的朋友一些可以直接抄作业的参考。
先说清楚caveman的目标场景:长时间沉浸式写代码的人,特别是需要在晚上或者光线暗的环境里连续工作四五个小时的开发者;同时它也是为那些对编辑器界面高度敏感、看到高饱和荧光色就烦躁的人准备的。跟很多主打“炫技”的渐变色主题不一样,caveman刻意把颜色数量控制在很少的范围内,用色尽量克制,高亮用语义色区分而非靠彩虹色铺满屏幕,降低视觉过载。
1. 项目发想与设计思路:为什么叫“caveman”
1.1 从“洞穴壁画配色”到代码编辑器的色彩哲学
这个主题的灵感来源,是西班牙阿尔塔米拉洞穴壁画里的那种颜色组合:赭石红、炭黑、骨白、土黄、暗棕。你会发现这些颜色有一个共同点——饱和度都不高,也没有蓝色系里那种非常亮眼的电光蓝。洞穴壁画的颜料来自天然矿物,并非古人审美有多高级,而是自然界里本来就没有高纯度的“显示器蓝”,人类眼睛在几百万年的进化中,最适应的其实是这种低饱和、中低明度的自然色域。
现代很多配色主题喜欢用#00FF00这种纯绿做字符串、用#4488FF做关键字,颜色单独拿出来确实很鲜明,但放到一个铺满代码的大屏幕上,高饱和色块之间会互相争夺注意力,看久了眼睛会因为频繁调节聚焦而疲劳。这和蓝光伤眼其实是两个层面的问题:蓝光是光谱层面的刺激,高饱和则是视觉注意力层面的负担。caveman的思路是同时避开这两个问题——把色板整体向棕红、土黄、暖灰偏移,蓝光波段占比自然就低了;再把饱和度压下来,让前景和背景的比例关系更接近自然纸张和墨水的对比度,而不是霓虹灯箱。
最终确定的主色板里,背景做成了接近洞穴岩壁的深灰色#232322,而不是纯黑#000000。纯黑背景在暗色环境里容易让前景字符产生“光晕感”,因为瞳孔在纯黑背景下会开大,白字边缘的对比度过高会出现视觉残影。深灰背景能让瞳孔维持在一个更自然的开合程度,同时给前景色留出足够对比度。前景用的是骨白偏暖的#d8d2c4,不是#ffffff那种冷白,暖白在一整天使用中会显得更温和。
1.2 主题的核心设计目标:护眼、聚焦、不打扰
caveman在设计阶段定下了三条硬性原则,开发过程中所有配色和细节都往这三条上靠。
第一条是“对比度足够但不暴力”。前景和背景的对比度比例我控制在 12:1 到 14:1 之间,这接近 WCAG 对普通文本 AAA 级的标准。单纯追求高对比度很容易,但高对比度带来的是阅读时的边界刺眼感。对比度不够又会让代码看不清。实测下来,#d8d2c4放在#232322上,夜间环境不刺眼,白天环境也能看清,是一个两者兼顾的平衡点。
第二条是“蓝光占比主动降下来”。这不是说把所有蓝色系字符都删掉,而是在需要表达“类型”“常量”这类语义时,选用偏青的暗蓝#7a828b,而不是亮蓝#569cd6。这类暗蓝在屏幕上实际发出的蓝光强度低很多,对于经常在夜间写代码的人来说,视觉上的间接刺激会小一些。当然,这不能替代护眼模式或者防蓝光软件,但作为主题层面能做的一部分,我会把蓝光量控制到最低。
第三条是“语法高亮用语义色而不是彩虹色”。caveman把语法角色分成六类:关键字、字符串、函数、类型、注释、操作符。每类都有固定的色相倾向,但所有颜色的明度和饱和度都在同一个视觉平面上,不会出现某个字符串突然跳出来把整个屏幕“点亮”的情况。这跟很多人喜欢的大瀑布流彩虹配色是相反的思路,但效果很稳定:你盯着代码工作时注意力会停留在代码含义上,而不是不停被颜色打断。
2. 核心技术拆解:一个 Neovim 配色主题是怎么工作的
2.1 配色主题的本质:Highlight Group 映射机制
要把一个主题做成真正的 Neovim 配色方案,首先要理解编辑器的渲染机制。Neovim 里每个语法元素其实都被打上了一个“高亮组”标签,比如关键字对应Keyword、字符串对应String、注释对应Comment、函数名对应Function、函数调用对应@function.call等等。配色主题本质上干的事情就一件:给这些高亮组分别指定前景色、背景色、粗体、斜体、下划线等属性。
可以把这想象成一个大型仓库管理系统的标签系统:语法解析器负责给每件货物贴标签,主题负责告诉仓库管理员“看到红色标签就把货放左边,看到蓝色标签就放右边”。如果某个高亮组没有定义颜色,Neovim 就会直接套用默认主题的颜色,这也是很多自写主题容易出现“某个地方颜色很突兀”的原因——不是主题没定义,而是定义漏了这个组。
在 Neovim 里,每条高亮规则是这样组织的:
vim.api.nvim_set_hl(0, "Comment", { fg = "#7d756c", italic = true })这行代码的意思是说:把Comment这个高亮组的前景色设置为#7d756c,同时加上斜体。nvim_set_hl是 Neovim 0.5 以后提供的 API,旧版本用的是:highlight命令加ctermfg和guifg。新 API 的好处是支持 Lua 调用的同时,还能在运行时动态修改,不需要重启编辑器就能看到配色变化,这对我开发主题时频繁微调颜色特别方便。
2.2 caveman 色板设计与命名规范
确定一个可靠的色板是写主题的第一步,也是最关键的一步。caveman的完整色板只有 14 个基础色,后面所有高亮组都是从这 14 个色里派生出来的。颜色一多就容易失控,颜色太少又会觉得单调。14 个颜色正好形成从背景到前景的一个渐变过渡带。
| 颜色名 | Hex 值 | 用途说明 |
|---|---|---|
| bg | #232322 | 编辑器主背景,偏暖深灰 |
| bg_alt | #2a2a29 | 侧边栏、浮窗、光标行背景 |
| bg_panel | #30302e | 浮窗底色、插件面板 |
| fg | #d8d2c4 | 主前景文字色,骨白偏暖 |
| fg_dim | #9a9387 | 次要文字、非活动区域 |
| comment | #756d62 | 注释颜色,比 fg_dim 再暗一档 |
| keyword | #bd8b6d | 关键字,土棕橙色 |
| string | #a89174 | 字符串,陶土色 |
| function | #d1b384 | 函数,淡琥珀色 |
| type | #8f9386 | 类型,灰绿色 |
| number | #b0876e | 数字、常量,深橙 |
| operator | #8e7e6e | 操作符,中灰棕 |
| error | #a5604b | 错误提示,暗砖红 |
| success | #707d64 | 成功、尖刻提示,灰绿 |
注意到这里没有出现纯蓝。type用的是灰绿色,error用暗砖红而不是亮红,function用淡琥珀而不是亮黄,所有这些选择都指向同一个原则:低饱和、低蓝光、同调性。色板里每个颜色的饱和度基本控制在 20% 到 35% 之间,明度集中在 45% 到 75% 之间,这样就保证了不管哪个高亮组站到一起,整体画面都是协调的。
从这 14 个基础色具体派生到高亮组的时候,我遵循了一个简单的规则:相同语法语义的角色使用从同一个基础色派生出的相近色。比如注释在普通代码里用comment,在行尾用comment,在代码折叠后的折叠摘要也用comment派生色,只是明度略有不同。这样避免了一个常见问题——同一个语义在文档的各个角落呈现完全不同的颜色,造成认知混乱。
2.3 为什么用 Lua 写配置而不是 Vimscript
早期 Vim 主题都习惯用 Vimscript 写,比如经典的gruvbox和onedark的老版本。现代 Neovim 生态里,新主题几乎都在向 Lua 迁移,caveman也直接选择了 Lua。这不是为了追新,而是有几个很实际的原因。
Lua 表结构写色板比 Vimscript 的 Dict 直观得多。比如定义色板的时候,Lua 可以一层层嵌套组织,高亮组定义也可以用for循环批量生成,配合nvim_set_hl在运行时应用。还有一个性能上的考虑:Lua 配置在 Neovim 启动时是直接执行的,比 Vimscript 解析执行要快,虽然主题加载本身开销不致命,但如果是几百个高亮组逐个用 Vimscript 的:hi命令去执行,加载速度会有明显差异。实测caveman用 Lua 加载完整套主题大约 3 毫秒,如果用 Vimscript 实现,同样的高亮数量至少需要 15 毫秒以上。
另外,Lua 写的主题更容易和 Lazy.nvim、Packer 这类现代插件管理器集成。插件管理器在加载主题时可以直接require("caveman")调用,不需要去设置全局变量再触发一个自动命令,链路更短,出错的环节更少。
3. 完整实操:从零开发 caveman 主题并安装到 Neovim
3.1 项目骨架与文件组织
caveman的项目结构遵循 Neovim 主题的通用约定,这样用户用插件管理器安装时不用做任何特殊处理:
caveman/ ├── colors/ │ └── caveman.lua ├── lua/ │ └── caveman/ │ ├── init.lua │ ├── palette.lua │ ├── groups.lua │ └── plugins.lua ├── README.md └── LICENSEcolors/caveman.lua这个路径很关键。Neovim 查找配色主题的机制是:当用户在配置里执行colorscheme caveman时,Neovim 会去runtimepath下的colors/目录里找caveman.vim或caveman.lua。如果找不到就报错。所以主题主入口必须放这里。
实际的加载逻辑则放在lua/caveman/目录下,这样用户也可以用require("caveman")在 Lua 层面手动调用。palette.lua只负责定义色板,groups.lua负责核心高亮组映射,plugins.lua负责和 Treesitter、LSP 等插件的适配,分工清晰,后面想改哪个模块就只动哪个文件。
3.2 核心配置代码实现
我把最核心的加载逻辑抽出来,你可以直接拿去改造成自己的主题。先看色板定义文件palette.lua:
local M = {} M.bg = "#232322" M.bg_alt = "#2a2a29" M.bg_panel = "#30302e" M.fg = "#d8d2c4" M.fg_dim = "#9a9387" M.comment = "#756d62" M.keyword = "#bd8b6d" M.string = "#a89174" M.function = "#d1b384" M.type = "#8f9386" M.number = "#b0876e" M.operator = "#8e7e6e" M.error = "#a5604b" M.success = "#707d64" return M这个文件里没有任何逻辑,只是颜色定义的集中地。第二步是groups.lua,把高亮组映射到色板,这是主题的核心部分:
local M = {} function M.setup(palette, config) local hl = vim.api.nvim_set_hl local c = palette -- 基础界面组 hl(0, "Normal", { fg = c.fg, bg = c.bg }) hl(0, "NormalFloat", { fg = c.fg, bg = c.bg_panel }) hl(0, "CursorLine", { bg = c.bg_alt }) hl(0, "LineNr", { fg = c.comment }) hl(0, "CursorLineNr", { fg = c.fg_dim }) hl(0, "Comment", { fg = c.comment, italic = true }) hl(0, "EndOfBuffer", { fg = c.bg_alt }) -- 语法高亮组 hl(0, "Keyword", { fg = c.keyword }) hl(0, "String", { fg = c.string }) hl(0, "Function", { fg = c.function }) hl(0, "FunctionCall", { fg = c.function }) hl(0, "Type", { fg = c.type }) hl(0, "Number", { fg = c.number }) hl(0, "Constant", { fg = c.number }) hl(0, "Operator", { fg = c.operator }) hl(0, "Identifier", { fg = c.fg }) -- 错误与警告 hl(0, "Error", { fg = c.error, bg = "NONE", bold = true }) hl(0, "DiagnosticError", { fg = c.error }) hl(0, "DiagnosticOk", { fg = c.success }) end return Minit.lua负责把色板和配置对接起来,提供统一的调用入口:
local M = {} local palette = require("caveman.palette") local groups = require("caveman.groups") -- 用户可自定义的配置项 local config = { transparent = false, -- 是否开启透明背景 bold_keywords = false, -- 是否加粗关键字 } function M.setup(opts) if opts and opts.transparent then config.transparent = true palette.bg = "NONE" end if opts and opts.bold_keywords then config.bold_keywords = true end groups.setup(palette, config) end -- 当用户执行 colorscheme caveman 时会走这个函数 function M.colorscheme() M.setup() end return M最后是colors/caveman.lua这个入口文件:
vim.api.nvim_command("hi clear") if vim.fn.exists("syntax_on") then vim.api.nvim_command("syntax reset") end vim.o.background = "dark" vim.g.colors_name = "caveman" require("caveman").colorscheme()注意这里先执行了hi clear和syntax reset,这是写主题的一个行规。如果不先清空当前所有高亮规则,旧主题的残留颜色会和你的新规则混在一起,界面会出现一半新主题一半旧主题的情况。
3.3 与主流插件生态的适配
caveman并不只是定义 Neovim 自身的高亮组就够了。实际开发中,编辑窗口里还会跑着 Treesitter、LSP 的诊断标记、Git 变更提示、Telescope 浮窗、nvim-cmp 补全弹窗等等,这些插件都有自己的高亮组命名空间,如果不做适配,插件界面就会用默认颜色显示,看起来跟你的主题完全是两个世界。
Treesitter 的适配是优先级最高的,现代 Neovim 里绝大多数语法高亮都走 Treesitter 而不是老的syntax模式。Treesitter 用@keyword、@string这类带前缀的组名,理论上如果 C 语言基础高亮组(如Keyword、String)定义了,Treesitter 捕获会自动回退到这些组。但实践中并不完全可靠,所以我单独把这些组映射了一遍:
hl(0, "@keyword", { fg = c.keyword }) hl(0, "@string", { fg = c.string }) hl(0, "@function", { fg = c.function }) hl(0, "@function.call", { fg = c.function }) hl(0, "@type", { fg = c.type }) hl(0, "@number", { fg = c.number }) hl(0, "@comment", { fg = c.comment, italic = true }) hl(0, "@operator", { fg = c.operator }) hl(0, "@variable", { fg = c.fg }) hl(0, "@variable.parameter", { fg = c.fg_dim }) hl(0, "@constant.builtin", { fg = c.number })LSP 部分主要处理诊断信息的颜色,这部分跟用户的开发体验关系最大,尤其是DiagnosticError和DiagnosticWarn,如果不指定颜色,就会用默认主题里那种刺眼的红和黄色:
hl(0, "DiagnosticError", { fg = c.error }) hl(0, "DiagnosticWarn", { fg = c.keyword }) hl(0, "DiagnosticInfo", { fg = c.type }) hl(0, "DiagnosticHint", { fg = c.comment }) -- 诊断下划线效果,实线会太刺眼,用虚线下划线 hl(0, "DiagnosticUnderlineError", { underline = true, sp = c.error }) hl(0, "DiagnosticUnderlineWarn", { underline = true, sp = c.keyword }) hl(0, "DiagnosticUnderlineInfo", { underline = true, sp = c.type }) hl(0, "DiagnosticUnderlineHint", { underline = true, sp = c.comment })Git 相关的适配也很简单。GitSignsAdd用灰绿色、GitSignsChange用淡琥珀、GitSignsDelete用暗砖红,延续主题的整体低饱和调性:
hl(0, "GitSignsAdd", { fg = c.success }) hl(0, "GitSignsChange", { fg = c.function }) hl(0, "GitSignsDelete", { fg = c.error })有一个细节值得注意:sp这个属性是下划线或波浪线的颜色。很多主题适配诊断下划线时只设了underline = true,没设sp,结果线是出来了,颜色却是默认的亮红,非常扎眼。设成主题的暗色系之后,诊断信息的“存在感”还在,但不会喧宾夺主。
3.4 集成安装与环境验证
写好了主题文件,怎么把它跟自己的 Neovim 配置整合起来?我用的 Lazy.nvim 写法如下:
{ dir = "~/projects/caveman", -- 本地开发目录,也可以改成 GitHub 地址 name = "caveman", lazy = false, priority = 1000, config = function() vim.cmd.colorscheme("caveman") end, }lazy = false, priority = 1000这两个配置很关键。配色主题必须在所有其他插件之前加载,否则后面的插件可能会覆盖它的高亮组。priority = 1000是最高的加载优先级,一定要保留。
安装完成后可以用一个简单的方法验证主题是否正确加载:在 Neovim 里执行:hi Normal,如果输出里前景色是#d8d2c4、背景色是#232322,说明主题的主入口加载成功。再执行:hi @function,看一下 Treesitter 的高亮组是否也映射上了。配合:Inspect命令,把光标放到代码的具体位置,可以精确看到这个 token 到底被分到了哪个高亮组。
启动时间的验证同样重要,用 Neovim 自带的--startuptime参数:
nvim --startuptime startup.log加载完成后打开startup.log,搜caveman,我实测的耗时是 3 到 4 毫秒。如果超过 10 毫秒,就要检查一下是不是在加载时有网络请求或者有大量不必要的计算。
4. 常见问题与排查技巧实录
4.1 终端下的颜色偏差问题
我遇到过最多的问题就是:在 Neovim GUI 里颜色很正,换了终端打开以后颜色全变了,甚至看不清注释。这通常不是主题本身的问题,而是终端色彩支持不到位。caveman的色板定义使用truecolor(24 位 RGB 颜色),如果你的终端环境没有开启真彩色,Neovim 就会把颜色自动转换成 256 色,转换过程中颜色会丢失精度,特别是那些低饱和度的灰色、棕色会明显偏掉。
排查步骤是按层级来的。首先看终端本身支不支持真彩色。以kitty和wezterm为例,这两个终端默认就支持。如果用tmux,检查一下.tmux.conf里的set -g default-terminal "screen-256color"是否被正确配置为tmux-256color,同时还需要在~/.bashrc或~/.zshrc里设置export TERM=xterm-truecolor。其次确认 Neovim 内部没有强制关闭真彩色,Neovim 0.7 以后默认开启,基本不需要额外配置。
一个很实用的验证方法:
echo $TERM如果输出是xterm-256color或者screen-256color,大概率真彩没有开。改成xterm-truecolor或者tmux-256color后再试。还有一个更直接的终端真彩色支持测试脚本,可以在终端里运行curl -s https://raw.githubusercontent.com/JohnMorris/colorcheck/main/check.sh | bash,如果输出显示有平滑渐变、没有明显的色带,说明真彩色已开启,否则就要先解决终端环境。
4.2 斜体与粗体的适配冲突
caveman的注释默认带了italic = true。这个设计在 GUI 版 Neovim 里很好看,但在大多数终端里,衬线和无衬线字体并没有真正的斜体版本,终端会强制用算法对字形做倾斜处理,有些中文字体会直接忽略斜体,导致注释和普通文本看起来一个样。更糟的情况是某些终端在显示斜体时会出现字形重叠、渲染错乱的情况。
解决方式有两个方向。一是直接从终端层面停用斜体,在配置全局关掉注释的斜体:
vim.api.nvim_set_hl(0, "Comment", { fg = "#756d62", italic = false })二是给用户一个开关,我最后选择了在init.lua里加了no_italic = false配置项,用户可以在自己的配置里打开:
{ dir = "~/projects/caveman", config = function() require("caveman").setup({ no_italic = true }) vim.cmd.colorscheme("caveman") end, }如果注释的斜体设置导致中文注释在终端里字体变虚,建议干脆关掉斜体。很多中文字体本身就没有斜体设计,关了以后视觉效果反而更稳定。
4.3 对比度与亮度调节技巧
每个显示器的亮度和色温校准都不一样,同一套主题在不同设备上观感可能差很多。caveman提供了一套轻量级的微调机制:通过setup({})传入style = "classic"或style = "warm"等不同的预设,并支持用户覆盖任意颜色的色值。
我给caveman增加了contrast配置选项。默认情况下背景色是#232322,如果你在比较亮的房间里觉得对比度不够,可以调暗背景:
-- 调暗背景,增强对比度 require("caveman").setup({ contrast = "high", override = { bg = "#1c1c1b", comment = "#63605a", } })对比度调高的场景一般出现在白天光线充足的办公室;对比度调低的场景则出现在夜晚、卧室这种全黑的环境。这里给一组我实际测试过的调节对照表:
| 设置 | bg | fg | comment | 适用场景 |
|---|---|---|---|---|
| 默认 | #232322 | #d8d2c4 | #756d62 | 大多数环境 |
| 柔和 | #2c2c2a | #cfc9bb | #7f7b72 | 夜间、低光环境 |
| 高对比 | #1a1a19 | #e6e0d1 | #5e5952 | 白天、外部光线强 |
| 高亮 | #272725 | #f0e9d8 | #84807a | 暗色背景+高亮屏 |
这个调法是整体移动色阶,不改变色调关系。不是简单地拉高亮度,而是把背景亮度往下压一档,同时把前景亮度往上抬一档,保证字符边缘的清晰度不丢失。
4.4 插件高亮组覆盖冲突
最后一个常见问题来自插件生态:明明caveman定义好了所有高亮组,但某个插件的界面还是显示默认颜色,甚至有的区域完全变成白底黑字。这类问题通常不是配置冲突,而是插件本身有自己的高亮组定义,并且在加载过程中覆盖了主题的规则。
排查思路很简单:打开 Neovim,进入出问题的插件界面,执行:hi <具体高亮组名>,比如TelescopePromptTitle,查看当前生效的颜色是哪来的。通常输出里会显示Last set from一栏,可以看到颜色是在哪里被最后赋值的。如果Last set from指向某个插件目录下的after/ftplugin或plugin/文件,那就可以确定这个插件在加载时覆盖了主题。
解决方案也比较直接。在caveman的plugins.lua里,我会专门加一个after/colors的加载顺序保证主题覆盖所有插件的高亮。具体来说,在 Neovim 配置里用 Lazy.nvim 时,给主题一个priority = 1000只保证主题先于插件加载,但如果插件在运行到一半时又设置了高亮,就需要用autocmd ColorScheme或autocmd VimEnter来强制在最后阶段再次应用主题:
vim.api.nvim_create_autocmd("ColorScheme", { pattern = "caveman", callback = function() require("caveman.plugins").setup() end, })另外还有一类问题来自nvim-cmp这类补全插件。它弹出的是一个独立浮窗,浮窗的高亮组如果没适配,会出现补全列表一片惨白的情况。我的处理方式是给Pmenu、PmenuSel、CmpItemKindKeyword这些组单独分配颜色:
hl(0, "Pmenu", { fg = c.fg, bg = c.bg_panel }) hl(0, "PmenuSel", { fg = c.bg, bg = c.function }) hl(0, "CmpItemKindKeyword", { fg = c.keyword }) hl(0, "CmpItemKindFunction", { fg = c.function }) hl(0, "CmpItemKindType", { fg = c.type })PmenuSel这里我用的是反色的思路:选中项的背景用淡琥珀色,文字用深灰背景色。这样补全列表的选中项非常显眼,又不至于像默认主题那样用一块高饱和蓝色块。有些插件作者喜欢在自己的插件里强制设置一大串高亮,这也是主题适配工作量最大的来源。遇到这种问题,最笨但最有效的方法就是用:Inspect逐个检查高亮来源,对不上了就在plugins.lua里对应位置补一个覆盖规则。
写主题这件事,我前后折腾了两三周,真正稳定下来是在第二周以后。回头看我最大的一个感受是:写配色主题和写业务代码很像,一开始总会忍不住加功能、加颜色、加各种细节,但实际用下来,最舒服的反而是把颜色砍到只剩十几个、把花样全删掉之后的状态。好的主题不是让你盯着屏幕感叹“颜色真漂亮”,而是让你完全感觉不到主题的存在——当你连续写四五个小时代码抬起头,发现眼睛没有以前那种干涩发胀的感觉,这就说明主题做对了。
最后再分享一个小技巧:主题上线以后,不要急着一次性把所有高亮组都适配完,先保证日常写代码用到的那几个核心组(基础文本、注释、字符串、关键字、函数、类型)是稳定的,然后带着它真实工作两三天,把那些“看着别扭”的地方记录下来,再回来逐批微调。你现在记下的每一个“别扭”点,都会变成主题里最有价值的那部分定制项。