Linux 按键体系 11:编辑层 Neovim

本文是 Linux 按键体系系列 的第十一篇,也是逐层巡视的最后 一站。Neovim 在责任链的最内层,它拿到的是前面六层都不要的键——但也正因为在最内层, 它的键位空间最大,管理难度也最高。

键位规模的量级差异

先看规模。我的配置里:

绑定数量
AwesomeWM ~45
WezTerm 11
tmux ~20(含插件)
zsh 1 条自定义(其余靠默认)
Neovim 100+

Neovim 的键位数量比其他所有层加起来还多。这意味着它面临的核心问题跟其他层不同

leader 是逗号

-- 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 这边有个更好的消费者。

which-key:可发现性

-- 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 这个数字

这是一个需要调的参数:

300ms 的含义是:流畅输入时看不到它,一旦犹豫它就出现。 它只在你需要的时候 存在——这是一个界面元素能做到的最好状态。

注意这个值覆盖了 viml/conf.vim 里的旧设置:

set tm=500

加载顺序是 basic.luaviml/conf.vimoptions.lua → lazy 插件,which-key 在 VeryLazy 时执行,所以 300 生效。这种"两处设置同一个选项"是配置演化的常见残留, 应该清理掉——留着它,下次你调 timeoutlen 时可能会改错地方然后困惑半天。

operators = false

plugins = { presets = { operators = false } },

关掉操作符(dyc)的提示。因为这些已经是肌肉记忆,弹窗只会碍事。

这条配置体现了一个重要判断:可发现性工具应该只覆盖"你可能忘记"的部分。对已经 形成本能的操作显示提示,是把辅助工具变成噪音。

key-analyzer:维护工具

{ '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","在哪一层" 由修饰键决定,而修饰键在物理上是分开的第六篇 的 物理分离表)。

命令行模式用 emacs 键位

" 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+1Alt+9 跳转到第 N 个 buffer。

但 WezTerm 抢走了前六个第八篇):

{ key = "1", mods = "ALT", action = act({ ActivateTab = 0 }) },
-- ... 一直到
{ key = "6", mods = "ALT", action = act({ ActivateTab = 5 }) },

第六篇 的责任链,WezTerm 在第 ④ 层,Neovim 在第 ⑦ 层。Alt+1Alt+6 在 WezTerm 就被消费掉了,根本不会编码成字节送进 PTY。

实际行为是:

按键 实际效果
Alt+1Alt+6 切换 WezTerm tab
Alt+7Alt+9 跳转 Neovim buffer

同一组连续的按键,前六个和后三个行为完全不同。 这是典型的跨层冲突,而且是最 难受的那种——不是"不工作",是"工作,但做的是另一件事"。

第六篇 的诊断流程,第 4 步就能定位:cat -v 里按 Alt+7 有输出(^[7),按 Alt+1 什么也没有。

修法有三个方向:

  1. 让 WezTerm 让路——去掉 Alt+1Alt+6,改用 Ctrl+Shift+16 (CSI u 下可用,且是终端层的天然领地)。
  2. 让 Neovim 让路——buffer 跳转改用 ,1,9(leader 前缀,零冲突)。
  3. 接受现状,但那意味着永久保留一个不可推导的例外。

第六篇 的"越低层越克制"原则,方案 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 })

这个设计模式值得单独记住:与其检测错误再提示用户修正,不如让"错误的输入"直接产生 正确的结果。 用户甚至不会意识到自己按错了。

完整的输入法切换机制是下一篇的主题。

小结

  1. Neovim 的问题不是冲突而是规模:一百多个绑定,核心挑战是可发现性和可维护性。
  2. 分组前缀(,f 查找、,s 符号、,c 复制)把记忆量从 N 降到 log N, 小写/大写区分常用与扩展变体。
  3. which-key + 300ms 超时:流畅时看不见,犹豫时出现;operators = false 保证 它不对肌肉记忆已覆盖的操作说话。
  4. 跨层一致性优先Ctrl+hjkl 在三层含义相同;命令行模式用 emacs 键位跟 zsh 统一。
  5. 核心键位只用传统编码能表达的组合,因为 CSI u 会静默降级,而静默降级比功能 缺失更难察觉。
  6. Alt+1Alt+6 被 WezTerm 抢走是一个真实存在的跨层冲突——分散在两个仓库里的 配置,单看任一文件都没问题。这就是需要跨层总表的理由。
  7. 覆盖标准键要求新功能明显更高频且原功能有替代;能用 leader 前缀就别覆盖。
  8. 全角标点直接映射成半角:让错误的输入产生正确的结果,比检测并提示更好。

下一篇是系列的核心问题之三,把三处输入法自动切换彻底讲清楚: 中英文自动切换:fcitx5 + rime 三处联动