本文是 Linux 按键体系系列 的第十一篇,也是逐层巡视的最后 一站。Neovim 在责任链的最内层,它拿到的是前面六层都不要的键——但也正因为在最内层, 它的键位空间最大,管理难度也最高。
先看规模。我的配置里:
| 层 | 绑定数量 |
|---|---|
| AwesomeWM | ~45 |
| WezTerm | 11 |
| tmux | ~20(含插件) |
| zsh | 1 条自定义(其余靠默认) |
| Neovim | 100+ |
Neovim 的键位数量比其他所有层加起来还多。这意味着它面临的核心问题跟其他层不同:
-- lua/basic.lua
vim.g.mapleader = ','
vim.g.maplocalleader = ','
现在主流选择是空格。, 是我多年前的选择,沿用至今。
诚实评估:空格更好。它在两只手的拇指下,左右手都能按;, 需要右手食指离开
j 位。而 , 本身的原始功能(重复 f/t 查找的反方向)是有用的。
但迁移成本是真实的——一百多个绑定的肌肉记忆,加上 , 开头的组合已经形成了下意识
动作。这类"知道更好但不改"的决定在长期维护的配置里很常见,重要的是知道它是个
遗留选择,而不是把它合理化成设计。
, 也有一个意外的好处,下面讲全角标点时会说到。
一百多个绑定不可能靠死记。唯一可行的办法是分组,让 leader 后的第一个键表示 类别:
| 前缀 | 类别 | 例子 |
|---|---|---|
,f |
find(查找) | ,ff 找文件、,fg 全文搜索、,fb 当前 buffer 搜索、,fd 诊断 |
,s |
symbol(符号) | ,ss 文档符号、,sS 工作区符号 |
,c |
copy(复制路径) | ,cp 相对路径、,cP 绝对路径、,cf 文件名 |
,e / ,y |
翻译 / 词典 | ,ee、,yd |
,h / ,l |
语法树移动 | move_out / move_in |
,q |
quickfix 开关 | |
,b |
buffer 列表 |
规律:
小写 = 常用变体 ,cp 复制相对路径(更常用)
大写 = 扩展变体 ,cP 复制绝对路径
,ff / ,fg / ,fb / ,fz / ,fr / ,fs / ,fq / ,fd / ,fn —— 九个查找
入口共用一个前缀。记住"查找相关的都在 ,f 下面"比记住九个独立组合容易一个
数量级,而且加第十个的时候你知道该往哪放。
再看这三条的写法:
-- lua/basic.lua
vim.keymap.set('n', '<leader>cp', function()
local path = vim.fn.expand '%:.'
vim.fn.setreg('+', path)
vim.notify('Copied: ' .. path)
end, { desc = 'Copy relative path' })
每条都有 desc。 跟 第七篇 里 awesome 的 description
是同一件事——配置即文档。区别是 Neovim 这边有个更好的消费者。
-- lua/plugins.lua
{
'folke/which-key.nvim',
event = 'VeryLazy',
config = function()
vim.o.timeout = true
vim.o.timeoutlen = 300
require('which-key').setup {
preset = 'helix',
plugins = {
presets = {
operators = false,
},
},
}
end,
},
按下 , 停顿 300ms,弹出所有 , 开头的绑定,按 desc 分组显示。
这是系列主线"减少状态确认"在编辑层最直接的实现。 对比两种回忆键位的方式:
方式 A(查配置):想不起来 → 打开配置文件 → 搜索 → 理解 → 切回来
上下文切换,思路断掉
方式 B(which-key):按下 , → 停顿 → 看到列表 → 继续
不离开当前位置,不中断
关键区别不是省了多少时间,是有没有离开当前上下文。 方式 A 里你已经在读配置 文件了,回来时原来在想什么已经淡了。
timeoutlen = 300 这个数字这是一个需要调的参数:
,ff 时也会闪一下弹窗,变成干扰。300ms 的含义是:流畅输入时看不到它,一旦犹豫它就出现。 它只在你需要的时候 存在——这是一个界面元素能做到的最好状态。
注意这个值覆盖了 viml/conf.vim 里的旧设置:
set tm=500
加载顺序是 basic.lua → viml/conf.vim → options.lua → lazy 插件,which-key 在
VeryLazy 时执行,所以 300 生效。这种"两处设置同一个选项"是配置演化的常见残留,
应该清理掉——留着它,下次你调 timeoutlen 时可能会改错地方然后困惑半天。
operators = falseplugins = { presets = { operators = false } },
关掉操作符(d、y、c)的提示。因为这些已经是肌肉记忆,弹窗只会碍事。
这条配置体现了一个重要判断:可发现性工具应该只覆盖"你可能忘记"的部分。对已经 形成本能的操作显示提示,是把辅助工具变成噪音。
{ 'meznaric/key-analyzer.nvim', event = 'VeryLazy', opts = {} },
:KeyAnalyzer <前缀> 会画一个键盘布局图,标出哪些键在这个前缀下已经被占用。
这解决的是"加新绑定时怎么知道哪些键还空着"。 没有它的话,你要么全文搜索配置 (会漏掉插件自带的绑定),要么绑上去试(可能静默覆盖已有的)。
这类工具的价值随配置规模超线性增长。二十个绑定时用不上,一百个绑定时它是唯一能让你 安全地继续加绑定的东西。
Neovim 里最值得强调的不是它自己的键位,而是它跟其他层共享的那些。
" viml/conf.vim
" Smart way to move btw. windows
map <C-j> <C-W>j
map <C-k> <C-W>k
map <C-h> <C-W>h
map <C-l> <C-W>l
Ctrl+hjkl 切窗口。跟 第七篇 的 Mod+hjkl、
第九篇 的 prefix hjkl 语义完全一致。
三层,三个不同的修饰键,同一套方向键。你的手指只需要知道"往左是 h","在哪一层" 由修饰键决定,而修饰键在物理上是分开的(第六篇 的 物理分离表)。
" Bash like keys for the command line
cnoremap <C-A> <Home>
cnoremap <C-E> <End>
cnoremap <C-K> <C-U>
cnoremap <C-B> <Left>
cnoremap <C-F> <right>
cnoremap <C-P> <Up>
cnoremap <C-N> <Down>
" <C-d>: delete char.
cnoremap <C-d> <Del>
这是本文最值得推广的一条。 Neovim 的命令行(: 之后)和 zsh 的命令行是同一
类界面:单行、短、以输入为主。第十篇 论证过这类场景下 emacs
键位优于模态。
把它们统一之后,Ctrl+a 回行首这个动作在两个完全不同的程序里含义相同。你不
需要先想"我现在是在 shell 还是在 vim 的命令行"——这正是"减少状态确认"。
cnoremap <C-K> <C-U> 这条稍特殊:Vim 的 Ctrl+u 是"删到行首",而 readline 的
Ctrl+k 是"删到行尾"。这里把 Ctrl+k 映射到 Vim 的 Ctrl+u……方向其实是反的。
下面那条自定义的 cnoremap <C-k> 才是真正实现"删到行尾"的:
" <C-k>, K: delete to end.
cnoremap <C-k> <C-\>e getcmdpos() == 1 ?
\ '' : getcmdline()[:getcmdpos()-2]<CR>
后定义的覆盖前面的,所以 Ctrl+k 最终是"删到行尾",跟 readline 一致。但上面那条
<C-K> 成了死代码——又一处该清理的历史残留。
第三篇 列过传统终端下绑不了的组合。回头检查我的配置, 核心键位全部避开了它们:
| 传统编码不可用 | 我的配置是否使用 |
|---|---|
<C-i> / <Tab> 区分 |
否 |
<C-m> / <CR> 区分 |
否 |
<C-[> / <Esc> 区分 |
否(且 第十二篇 反而利用了它们相同) |
<C-S-字母> |
否 |
<S-CR> |
否 |
这是刻意的。虽然 WezTerm 和 tmux 都开了 CSI u (第八篇、第九篇),本地环境下这些组合可用,但:
CSI u 链条上任何一环没配好,都会静默降级。 你按
<C-i>得到<Tab>的行为, 而且没有任何报错。
ssh 到一台没配过的服务器,或者临时用别人的终端,配置就变了行为。静默降级比功能 缺失更伤——功能缺失你立刻知道,静默降级你要花时间才能意识到。
所以我的原则是:核心操作只用传统编码能表达的组合,CSI u 留给锦上添花的绑定。
配置里有这么一组绑定:
-- lua/plugins.lua,nvim-smartbufs
{
'johann2357/nvim-smartbufs',
keys = (function()
local keys = {}
for i = 1, 9 do
table.insert(keys, {
'<A-' .. i .. '>',
'<Cmd>lua require("nvim-smartbufs").goto_buffer(' .. i .. ')<CR>',
desc = 'Go to buffer ' .. i,
mode = 'n',
})
end
return keys
end)(),
},
Alt+1 到 Alt+9 跳转到第 N 个 buffer。
但 WezTerm 抢走了前六个(第八篇):
{ key = "1", mods = "ALT", action = act({ ActivateTab = 0 }) },
-- ... 一直到
{ key = "6", mods = "ALT", action = act({ ActivateTab = 5 }) },
按 第六篇 的责任链,WezTerm 在第 ④ 层,Neovim 在第 ⑦
层。Alt+1 到 Alt+6 在 WezTerm 就被消费掉了,根本不会编码成字节送进 PTY。
实际行为是:
| 按键 | 实际效果 |
|---|---|
Alt+1…Alt+6 |
切换 WezTerm tab |
Alt+7…Alt+9 |
跳转 Neovim buffer |
同一组连续的按键,前六个和后三个行为完全不同。 这是典型的跨层冲突,而且是最 难受的那种——不是"不工作",是"工作,但做的是另一件事"。
用 第六篇 的诊断流程,第 4 步就能定位:cat -v 里按
Alt+7 有输出(^[7),按 Alt+1 什么也没有。
修法有三个方向:
Alt+1–Alt+6,改用 Ctrl+Shift+1–6
(CSI u 下可用,且是终端层的天然领地)。,1–,9(leader 前缀,零冲突)。按 第六篇 的"越低层越克制"原则,方案 1 更正确:终端层 应该给应用层让路。而按"前缀优于组合键",方案 2 也不错。
写这篇文章的过程本身就是这个冲突被发现的过程——配置分散在两个仓库里,单看任何 一个文件都没有问题。这恰恰说明了为什么需要一张跨层的键位分配总表。
另一个值得讨论的取舍:
{
'aaronik/treewalker.nvim',
keys = {
{ '<c-d>', mode = { 'n', 'v' }, function() require('treewalker').move_down() end, desc = 'Treewalker move_down' },
{ '<c-u>', mode = { 'n', 'v' }, function() require('treewalker').move_up() end, desc = 'Treewalker move_down' },
{ '<leader>h', mode = { 'n', 'v' }, function() require('treewalker').move_out() end, desc = 'Treewalker move_out' },
{ '<leader>l', mode = { 'n', 'v' }, function() require('treewalker').move_in() end, desc = 'Treewalker move_in' },
},
},
Ctrl+u / Ctrl+d 被从"上下翻半页"改成了"在语法树上移动到上/下一个同级节点"。
这是 Vim 里最标准的两个键之一。覆盖它们的代价:
收益是:语法树移动是结构化的,比翻页更符合"阅读代码"的实际动作。读代码时你想 去的是"下一个函数"、"下一个 case 分支",不是"往下 20 行"。
我认为这个交换是划算的,但它是一个需要明确意识到的交换,而不是随手绑上去的。 判断标准可以是:
覆盖一个标准键,要求新功能的使用频率明显高于原功能,而且原功能有替代路径。
Ctrl+u/Ctrl+d 满足:结构化移动确实比翻页常用,而翻页可以用 Ctrl+f/Ctrl+b
或搜索代替。
<leader>h / <leader>l 做 out/in 则是另一种思路:语法树的"进出"用 leader
前缀,没有覆盖任何东西,而且 h/l 的左右语义跟树的层级刚好对应。
" viml/conf.vim
set guicursor=i:block
把插入模式的光标设成方块——跟普通模式一样。这等于放弃了"用光标形状指示模式"。
配套的是 hardline 状态栏:
local function set_hardline_statusline(active)
local statusline = active and [[%{%luaeval('require("hardline").update_statusline(true)')%}]]
or [[%{%luaeval('require("hardline").update_statusline(false)')%}]]
utils.set_local_window_option(0, 'statusline', statusline, { float_value = '' })
end
require('hardline').setup {
bufferline = true,
...
}
模式指示从光标移到了状态栏。这个取舍值得讨论:
| 光标指示 | 状态栏指示 | |
|---|---|---|
| 位置 | 视线正在看的地方 | 屏幕底部 |
| 需要移动视线 | 否 | 是 |
| 跨终端一致性 | 依赖终端支持 DECSCUSR |
总是可用 |
| 与终端配置的交互 | 可能被终端的光标设置覆盖 | 无 |
按系列标准,光标指示更优——它在视线已经在的位置。状态栏指示需要一次眼动, 而眼动就是一次微小的注意力转移。
改回来很简单:
set guicursor=n-v-c:block,i-ci-ve:ver25,r-cr:hor20
不过要注意它跟 第八篇 里 force_reverse_video_cursor = true 的
交互——细竖线在反色渲染下可能不够醒目。这正是跨层配置的典型特征:任何一层的
改动都要检查它在相邻层的表现。
最后这组映射是整个系列里我最喜欢的一个设计:
-- lua/options.lua
-- Full-width punctuation commits instantly (no candidate window), so it
-- reaches normal mode as a multibyte keypress; remap it to the intended
-- half-width key and correct the input method on the way.
local fullwidth_keys = {
[':'] = ':',
['?'] = '?',
['/'] = '/',
[','] = ',', -- leader
}
for fw, half in pairs(fullwidth_keys) do
map({ 'n', 'x' }, fw, function()
switch_to_en_for_client()
return half
end, { expr = true, remap = true })
end
场景:你在插入模式输入中文,按 Esc 回到普通模式,但输入法还是中文态。这时你按
: 想执行命令,实际输入的是全角的 :。
传统结果:普通模式收到一个无意义的多字节字符,什么也不做(或者报错)。你得意识到 发生了什么、切输入法、重按。
这里的做法是:把全角标点直接映射成对应的半角键,同时顺手把输入法切回英文。
于是"错误"消失了——: 就是 :,, 就是 leader。错误恢复成本降到零,因为
根本没有错误发生。
注意 [','] = ',' 这一条:因为 leader 是逗号,全角逗号也成了 leader。这就是前面
说的", 作为 leader 的意外好处"——中文输入中最高频的标点,恰好是 leader 键。
还有一条同样思路的:
-- Letters typed while the IM is composing are consumed by the IM and
-- never reach nvim, so they cannot be remapped. Pressing <Esc> cancels
-- the composition; the next <Esc> lands here and resets the IM.
map({ 'n', 'x' }, '<Esc>', function()
switch_to_en_for_client()
return '<Esc>'
end, { expr = true })
这个设计模式值得单独记住:与其检测错误再提示用户修正,不如让"错误的输入"直接产生 正确的结果。 用户甚至不会意识到自己按错了。
完整的输入法切换机制是下一篇的主题。
,f 查找、,s 符号、,c 复制)把记忆量从 N 降到 log N,
小写/大写区分常用与扩展变体。operators = false 保证
它不对肌肉记忆已覆盖的操作说话。Ctrl+hjkl 在三层含义相同;命令行模式用 emacs 键位跟 zsh
统一。Alt+1–Alt+6 被 WezTerm 抢走是一个真实存在的跨层冲突——分散在两个仓库里的
配置,单看任一文件都没问题。这就是需要跨层总表的理由。下一篇是系列的核心问题之三,把三处输入法自动切换彻底讲清楚: 中英文自动切换:fcitx5 + rime 三处联动。