先说个每天都能遇到的场景。我主要用 Emacs 写 Go 和 elisp,偶尔也写点 Markdown 文档。上午还在代码里敲fmt.Println,中午切到 git commit 里补中文说明,下午又钻回代码库调逻辑。一天下来,被输入法折腾的次数比被 Code Review 打回的次数还多。后来我折腾了两周,终于用context-mode把这个问题压下去了。现在进入 Emacs 之后,我基本不需要再手动关心系统输入法在中英文之间切换的事情。
context-mode并不是一个输入法本身,而是 Emacs 里一个负责“翻译上下文、自动切换输入法”的小组件。它通过判断当前 buffer、当前光标位置、当前 major-mode 等条件,让你的 Emacs 在恰当的时机调用系统输入法切换命令:你在代码注释里写中文,它自动切中文;你切到 minibuffer 敲命令,它自动切回英文。这篇文章就把我的配置过程、原理理解、踩坑记录完整写出来,给同样被中英切换问题烦到的人一个可以直接抄作业的方案。
1. 为什么我最后选定了 context-mode 来处理 Emacs 的中英切换
1.1 长期被输入法打断的编码节奏
先复盘一下问题本身。在 Emacs 里写中文和写代码,是两个完全不同的输入语境。写代码时,几乎所有按键都应该按英文输入法处理,包括;、{}、/这些符号;写中文注释或写文档时,我需要系统输入法处于拼音状态。问题在于,Emacs 不知道“我现在的意图”,它对你的输入法状态也一无所知。系统输入法在中英文之间切换,本质上是操作系统的状态变化,Emacs 内部没有对应的事件可以捕获。
所以大多数人一开始的做法是手动切。但手动切有两个很难受的地方。第一,切出去再切回来本身就是打断,尤其是你正盯着屏幕想一段复杂逻辑的时候,突然要低头看一眼输入法状态栏,节奏就断了。第二,你很容易“忘切”。最常见的就是在代码里写了一段中文注释,切回正文时忘记调回英文,结果下一个字母直接变成中文,一整行代码就废了。在 org-mode 里写文档更痛苦,你经常在中英文之间来回移动光标,每次移动都得想一下“我现在应该是什么输入法”。
这种情况不是“忍一忍就好”的,它对效率的损耗是持续的。我统计过自己一个下午的切换次数,大概有三十多次。每次切换按三秒算,一个多小时就浪费在无意义的切换操作上,而且中间还夹杂着误输入。
1.2 我试过的几种方案和它们各自的问题
在落到context-mode之前,我先后试过四五种思路,也算帮后来的人踩了一圈雷。
第一种是最粗暴的:只在 Emacs 里用英文输入法,中文全部通过M-x toggle-input-method(默认C-\)切到 Emacs 内置的输入法。这个方案的问题在于,内置输入法的拼音体验和系统输入法差距太大,词库、候选词、双拼支持都跟不上,写长文的时候完全不够用。而且它仍然需要手动切,只不过把“切系统输入法”换成了“切 Emacs 输入法”,本质没有变。
第二种是给特定 major-mode 写 hook,在prog-mode启动时强制切成英文,进入org-mode时强制切成中文。这个思路方向是对的,但粒度太粗。我长期开着一个 org 文件记录工作日志,里面既有中文段落,也有很多 ASCII 代码块和表格;它在 org buffer 里强制切中文,导致我在输入[[https://...]]链接时还得手动切英文,反而更烦。
第三种是全局开启看管输入法的守护进程,监听光标移动事件。这个理论上是完美的,但实际上是灾难:每次光标移动都触发一次输入法状态检查,短时间内连续移动光标会带来肉眼可见的延迟,还会跟部分补全插件抢焦点,经常出现“输入法状态已经切换了但光标位置还没更新”的错位。
最终试到context-mode,我才发现它走了另一个思路:不监听每个键,不改写输入法,只在“上下文发生关键变化”的时候做一次判断,需要切才切。这样既轻量,又能在粒度上精准控制。
2. context-mode 的工作方式:一次自动切换背后的判断链
2.1 这个包的三个核心概念
用过头之后,我觉得context-mode的设计可以抽象成三个核心概念,理解了这三个东西,配置起来就不会迷糊。
第一个是触发时机。context-mode是一个全局 minor-mode,它在激活之后会挂载到多个关键 hook 上,包括post-command-hook、after-change-major-mode-hook、minibuffer-setup-hook等。它并不会在每个键盘事件上都跑一遍判断,而是等这些“上下文可能变化”的事件发生后,再决定要不要重新评估当前环境。
第二个是条件列表,对应变量context-mode-conditions。这是一组条件的集合,每个条件返回t或nil,表示“当前上下文是否命中某种情况”。你可以把条件写成(major-mode . prog-mode)这种简单的 cons cell,也可以写成任意返回布尔值的函数。context-mode会按顺序逐个评估这些条件,然后得到一个统一结论:当前该用中文还是英文。
第三个是执行命令,对应变量context-mode-command。这个变量保存一个函数,接收一个参数on-off,t表示“该切到中文”,nil表示“该切回英文”。这个函数就是实际调用系统输入法切换通道的地方。不同操作系统、不同输入法框架,这里写的命令不一样,但接口统一,把差异都封装在这一层。
这三者之间的关系很像一个回调链:Emacs 把“上下文变了”这件事发给context-mode,context-mode跑一遍条件列表得到“现在是中文场景/英文场景”,最后调用context-mode-command执行真正的输入法切换。条件负责决策,命令负责执行,两者彻底分离。
2.2 context-mode 在什么时机触发
很多人第一次装上context-mode会有一个困惑:为什么明明光标在同一个 buffer 里移动,它有时候切输入法,有时候不切?这就是因为触发时机不是“移动”本身,而是“移动之后的状态”。
具体来说,context-mode会在这些场景里重新评估一次上下文:
- 切换到另一个 buffer 时。这是最频繁也最重要的触发点。从一个代码文件切到 org 文件,它立刻判断出需要换输入法。
major-mode发生变化时。如果你手动M-x org-mode把一个文本文件切到 org,它也会触发。- 在同一个 buffer 内部移动光标之后。这部分受限于
post-command-hook,但包内部做了节流处理,避免在快速滚动光标时反复执行外部命令。 - 进入 minibuffer 时。比如你按
M-x准备敲命令,它会把输入法强制切回英文,避免你在 minibuffer 里打出中文命令名。
实际使用中,最明显的效果是:在 org 文件里把光标从一段中文文字上移到一段英文代码块上,输入法会跟着切换。在同一个段落内移动光标,因为上下文没有实质变化,它不会频繁调用系统命令,所以不会觉得卡。
2.3 与其他方案的设计差异
理解context-mode,最好的参照系是“普遍的 hook 方案”和“全局守护方案”之间的差异。
普遍的 hook 思路是:只在after-change-major-mode-hook这种粗粒度时机做一次切换。它的优点是简单,缺点是只在模式切换时生效,在同一个文件里“有时想写英文、有时想写中文”的场景下无能为力。
全局守护思路前面说过,是极端地对所有事件做反应。它的问题在于把“判断”和“执行”耦合得太强,一次光标移动就对应一次输入法切换,性能开销和误触发概率都高。
context-mode的方案介于两者之间:它保留了细粒度判断能力,但把频繁的“状态检查”变成低频的“条件评估”。换句话说,它不是为了响应每一次移动,而是为了响应每一次“上下文环境的实质改变”。这是一个非常工程化的取舍,也是我后来认可它、并且推荐给身边同事的原因。
3. 从零配置到能用:安装与最简启用
3.1 安装前先确认输入法切换通道
如果你现在就准备动手,第一步不是往init.el里写use-package,而是先确认你的系统能不能被命令行切换输入法。context-mode本身只是一个触发器,它最终要做的事情是执行一条系统命令。如果这条命令不通,后面配置得再漂亮也白搭。
Linux 桌面环境下,我推荐用 fcitx5,它自带一个非常稳定的命令行工具fcitx5-remote。两条命令需要记住:
- 切到中文:
fcitx5-remote -o - 切回英文:
fcitx5-remote -c
注意,fcitx5 的“输入法状态”和“当前输入法”是两个概念。-o表示打开中文输入,-c表示关闭并回到英文状态。如果你配置了多个输入法,建议把中文输入法设为唯一激活项。
macOS 上通常用的是im-select这个命令行工具。它在 GitHub 上可以下载,本质是读取和设置当前输入源。命令格式是:
im-select # 查看当前输入源 im-select com.apple.keyboard.US # 切到英文 im-select com.apple.keyboard.zhuyin # 切到注音如果你的 macOS 用的是简体拼音,输入源 ID 可能是com.apple.keyboard.zhongwen之类。用im-select先确认一下现有 ID,避免写错。
Windows 上没有特别统一的命令行工具,大家用得比较多的是.NET写的im-select.exe,原理类似。确认好命令能在一个终端里正常执行后,再进行下一步。
提示:不要跳过这一步。很多人“装上之后不生效”,后来发现不是
context-mode的问题,而是底层切换命令根本没生效。
3.2 用 use-package 装好并跑起来
我所有配置都走use-package,context-mode也不例外。最简安装配置如下:
(use-package context-mode :ensure t :hook (after-init . global-context-mode) :custom (context-mode-command (lambda (on-off) (if (eq system-type 'gnu/linux) (call-process "fcitx5-remote" nil nil nil (if on-off "o" "c")) (call-process "im-select" nil nil nil (if on-off "com.apple.keyboard.zhuyin" "com.apple.keyboard.US"))))) (context-mode-conditions '(((not (eq major-mode 'minibuffer-mode)) . t))))这段配置做的事情很直接:加载包后立刻开启global-context-mode;定义切换函数,在 Linux 上调fcitx5-remote,在 macOS 上调im-select;条件列表暂时只设一条“非 minibuffer 环境都启用自动切换”。
这里有一个容易踩的点:context-mode-command的on-off参数,不同平台语义不同。fcitx5 的-o和-c是对应的;但im-select切换的输入法 ID 必须写完整。如果把中文输入法的 ID 写错了,会出现“该切中文时切了,但切到的不是你的拼音输入法”这种半失效状态。
3.3 最小可用配置与效果验证
跑起来之后,怎么验证它正常工作?我的建议是准备一个测试 buffer,执行以下三个检查:
- 打开一个全新 buffer(
M-x scratch),确认光标停留时输入法是英文。 - 在这个 buffer 里输入一行中文注释,比如“这是一个测试”,确认输入法可以被手动切到中文。
- 用
C-x b切到一个已经打开的代码文件,观察输入法是否自动变回英文。
如果第三步生效,说明条件评估和命令执行都已经正常跑通。如果没有生效,先检查M-x context-mode是否处于 enable 状态,再看*Messages*buffer 里有没有报错。最常见的错误是call-process的参数写错,尤其是字符串形式的输出参数,直接把错误信息扔到标准输出里,但命令本身没有真正执行。
这套最小配置只能覆盖最基本的需求,距离“真正好用”还差很远。接下来要做的,是把条件列表改成符合你个人习惯的规则。
4. conditions 条件列表:让切换规则真正符合你的工作流
4.1 conditions 是一张判断清单
context-mode-conditions是整篇配置的灵魂。它是一张有顺序的判断清单,每个元素作为一个条件,最终综合得出一个“中文/英文”的结论。
每个条件有两种常用写法。第一种是 cons cell:(condition . result),其中condition是一个表达式,result是t或nil。当condition为真时,这个条件贡献一个t的结果,否则贡献nil。第二种是纯函数:写一个 lambda 或者符号,返回值直接作为条件结果。多个条件之间是“或”的关系:任何一个条件命中了“切中文”,整体结论就是切中文;只有当所有条件都判定为“应该用英文”时,整体才是英文。
这个逻辑需要理解清楚,因为它是导致“感觉不听话”的核心原因。默认条件下,如果没有任何条件命中,context-mode会保持当前状态不变,而不是强制切换。也就是说,它是一个保守的组件:除非有明确规则说“这里该切中文”或“这里该切英文”,否则不动你的输入法。
4.2 我常用的几种条件写法
我的使用场景比较固定:代码和文档混编。针对这个场景,我把条件分成了四组,每一条都有明确目的。
第一组,针对代码文件:
(((derived-mode-p 'prog-mode) . nil)这条表示:如果当前 major-mode 派生自prog-mode,结论是“用英文”。它覆盖了所有主流的编程语言模式,包括 python-mode、go-mode、rust-mode、c-mode 等,因为它们全都继承自prog-mode。
第二组,针对特殊文本场景:
((and (eq major-mode 'org-mode) (org-in-src-block-p)) . nil) ((and (eq major-mode 'org-mode) (org-in-block-p '("example" "verse" "export"))) . nil)这两条处理的是 org-mode 里最头疼的情况:你在一个 org 文件里写中文笔记,但光标进入了某个 src 代码块或者 example 块,这时应该切回英文。只有限制在 org 场景里,才不会误伤普通文本。
第三组,针对 minibuffer 和特殊 buffer:
((bound-and-true-p minibufferp) . nil) ((derived-mode-p 'magit-mode) . nil) ((eq major-mode 'dired-mode) . nil)minibuffer 永远用英文,原因不必多说,你不想在M-x后面敲出中文。magit-mode 和 dired-mode 用英文,是因为这些界面里大量使用 ASCII 快捷键,输入法开着会严重干扰操作。
第四组,兜底逻辑:
((buffer-file-name . t) ((and (not buffer-file-name) (eq major-mode 'org-mode)) . t))这两个条件组合起来会产生一个比较舒服的行为:有文件名的 buffer 默认用中文,因为普通文档和注释场景更多;没有文件名但处于 org-mode 的 buffer 也用中文,因为那通常是我的临时 scratch 或者日志文件。这样兜底下来,剩下的场景保持原状态,不折腾。
4.3 条件顺序和优先级问题
条件列表是有顺序的,但顺序不等于优先级。前面说过,多个条件是“或”的关系,所以真正决定结果的是“是否出现一个切中文的条件”。如果你想实现“某些条件下强切英文,其余情况保持中文”,就要把强切英文的条件放在靠前的位置,并且把兜底切中文的条件放在最后。
我举一个实际调整过的例子。最初我把buffer-file-name的兜底中文条件放在第二位,结果是:在任何有文件名的编辑 buffer 里,只要光标一动,输入法就会自动切中文。我在一个 LaTeX 文件里输入大量公式时,这会变成灾难——每个公式区域的 ASCII 字符都会被中文输入法拦截。后来我把“代码模式强切英文”条件提到最前面,就解决了。所以写条件时,先想清楚哪些场景需要强切英文,这些条件优先级最高,需要强切中文的场景次之,兜底条件永远放最后。
注意:如果你在条件里用了
derived-mode-p,请确保对应的 mode 已经加载。某些 mode 是 lazy-load 的,比如yaml-mode可能在你打开 yaml 文件时才加载。这时如果条件写在context-mode-conditions里,而 mode 尚未加载,derived-mode-p会返回 nil,条件失效。解决方法是改用(member major-mode '(yaml-mode conf-mode))这种直接比较方式,或者在该 mode 的 hook 里再调用一次context-mode-toggle。
5. 中文用户最容易踩的几个深坑
5.1 系统输入法框架选择造成的“失灵”
第一个坑是输入法框架不统一。Linux 下常见的输入法框架有 fcitx4、fcitx5、ibus。context-mode-command里调用fcitx5-remote的前提是你确实装了 fcitx5 并且它是当前会话的输入法管理器。如果桌面环境默认走的是 ibus,那fcitx5-remote这条命令会直接报错或者什么都不做。
判断方法很简单,在终端里执行:
fcitx5-remote -c执行完以后手动敲几个字母,看是否有输入法状态变化。如果没有任何反应,说明 fcitx5 不在运行。这时候你有两条路:要么把系统输入法切换到 fcitx5,要么把context-mode-command改成适配当前框架的命令。
ibus 生态目前没有特别稳定的远程切换命令,我印象里ibus自带的ibus engine和ibus-setup都不能在命令行直接控制当前输入法状态。所以如果你已经被 ibus 绑定,建议考虑换到 fcitx5,而不是死磕命令行适配。fcitx5 现在对 GTK/Qt 程序的支持都很完整,日常使用基本无感。
第二个容易踩的是双拼用户状态差异。fcitx5 的-o把输入法设为中文,这个“中文”指的是当前输入法分组里的第一个输入法。如果你在 fcitx5 里同时启用了拼音和五笔,且默认分组是五笔,那-o之后你得到的可能是五笔而不是拼音。建议在 fcitx5 配置里只保留一个中文输入法,避免这种不可预期的状态。
5.2 minibuffer 和临时 buffer 的干扰
minibuffer 的问题我在条件列表里已经处理过,但还有一个衍生问题:有些补全框架会临时创建特殊 buffer 来展示候选列表。比如ivy的候选列表是在 minibuffer 内部展示的,问题不大;但company-mode的补全弹窗是 overlay,不产生独立 buffer,所以context-mode评估上下文时会认为你还在原 buffer 里,此时输入法状态不会改变。
这种场景其实不需要改变。写代码时补全弹窗出现,你本来就在英文输入法状态下,没问题。真正要小心的是avy这类跳转工具,它会把候选字符临时显示在多个 buffer 上,甚至跳到 minibuffer 里等待输入。context-mode在avy等待输入时可能已经把输入法切成了中文,导致你输入目标字符时打出的是中文。我的规避方案是把avy绑定到单独按键,同时在avy的 setup hook 里显式切换英文输入法:
(with-eval-after-load "avy" (add-hook 'avy--setup-hook (lambda () (when context-mode-command (funcall context-mode-command nil)))))5.3 org-mode 里的注释块和 src 块
org-mode 是我最常用的工作环境,也是context-mode各种边界情况的重灾区。前文已经提到org-in-src-block-p,但这里还有两个更隐蔽的问题。
第一个是 org-mode 的表格环境。org 表格内部经常要输入大量数字和竖线|,如果输入法处于中文状态,|这个符号极容易被输入法拦截或转换成全角字符,表格格式直接崩掉。我对 org 表格的处理是:在org-table相关命令执行期间,临时禁用context-mode。实现方式可以绑定一个切换命令,或者直接调整条件:
((and (eq major-mode 'org-mode) (org-at-table-p)) . nil)第二个是 org 里的内联代码,比如=code=和~code~。快速判断光标是否在内联代码段里有点麻烦,org-mode 本身没有直接提供类似org-in-src-block-p的接口。我的做法是在条件列表里判断当前段落的形式:如果当前行内存在等号或波浪号包裹的片段,而且光标位于片段内,就切英文。这个判断写成函数比较清晰,但如果你对 org 没有这么极端的混排需求,可以先不处理。我建议大多数用户先把 src 块和表格处理清楚,这已经覆盖了 95% 的干扰场景。
5.4 与 pyim 等 Emacs 输入法共存
如果你过去用过pyim这类纯 Emacs 输入法,可能会配置一些钩子来切换状态,比如pyim-mode和default-input-method。context-mode和这些插件共存时,最大的冲突点在于:两者都会试图控制输入法状态,但控制的维度不同。
pyim控制的是 Emacs 内部的input-method,context-mode控制的是系统输入法。如果你同时开着 pyim 和系统中文输入法,工作流程会变成:你在 Emacs 里输入时,pyim 先把字符拦截成候选词,然后系统输入法再拦截一次,两套逻辑叠加,候选词重复、按键冲突、状态混乱。
我的建议是,如果你已经决定使用context-mode走系统输入法路线,就把default-input-method设为 nil,并且不要启用 pyim。等 Emacs 27 以后内置的input-method机制也带来自动切换能力时,这种冲突会减少,但目前阶段,两套并存就是给自己找不痛快。
提示:如果你确实需要软件的词库、双拼方案等 Emacs 输入法特性,可以先把
context-mode-command屏蔽掉,仍然保留context-mode的条件判断能力,只让它在“需要切换”时调用toggle-input-method,这样至少不会出现两套输入法同时抢键的问题。不过体验仍然一般,不推荐长期使用。
6. 我现在的完整配置和最终使用效果
6.1 最终配置
这里是我目前在用的完整配置,适配 Linux + fcitx5 环境。你可以直接复制到init.el,再把系统相关部分改成自己的输入法工具路径。
(use-package context-mode :ensure t :hook (after-init . global-context-mode) :custom (context-mode-command (lambda (on-off) (if (eq system-type 'gnu/linux) (call-process "fcitx5-remote" nil nil nil (if on-off "o" "c")) (call-process "/usr/local/bin/im-select" nil nil nil (if on-off "com.apple.keyboard.zhuyin" "com.apple.keyboard.US"))))) (context-mode-conditions '( ;; 强切英文的场景,优先级最高 ((bound-and-true-p minibufferp) . nil) ((derived-mode-p 'prog-mode) . nil) ((derived-mode-p 'magit-mode) . nil) ((eq major-mode 'dired-mode) . nil) ((and (eq major-mode 'org-mode) (org-in-src-block-p)) . nil) ((and (eq major-mode 'org-mode) (org-at-table-p)) . nil) ;; 需要中文的场景 ((eq major-mode 'org-mode) . t) ((eq major-mode 'markdown-mode) . t) ((eq major-mode 'text-mode) . t) ((buffer-file-name . t)) ;; 完全兜底:保持现输入法状态 )))另外,我在org-mode-hook和markdown-mode-hook里都会额外调用一次context-mode-toggle,让刚进入文件时就能立即做一次评估,而不是等到光标第一次移动才生效:
(add-hook 'org-mode-hook (lambda () (when (bound-and-true-p context-mode) (context-mode-toggle)))) (add-hook 'markdown-mode-hook (lambda () (when (bound-and-true-p context-mode) (context-mode-toggle))))6.2 实际使用效果与建议
这套配置我用了大概三个月,最后的感受可能有代表性:它完全解决了我“代码与文档切换”场景下的输入法烦恼,但并不是一个万能开关。它最舒服的使用方式,是配合一套合理的目标导向规则:你能准确说出“哪种 buffer 下用中文、哪种 buffer 下用英文”,它就能准确执行。如果你的工作流非常发散,连自己都说不清当前屏幕上的内容应该用哪种语言,那context-mode也会跟着发飘。
我自己的使用体验集中在三件事上。第一,commit message 写得舒服多了。以往每次写中文 commit 都要先在编辑器里切一次输入法,现在只要你光标停在 magit 的 commit buffer 里,它自动是英文,但跳回到 diff 里的说明区域,中文直接可用。第二,org-mode 写文档时的光标移动不再产生输入法焦虑。第三,minibuffer 永远英文,这个细节救了太多次手误。
最后留一个顺手的小技巧:把context-mode-toggle绑定到一个快捷键上,以防某些极端场景下自动判断不符合需求。我是这样绑的:
(global-set-key (kbd "C-c i s") #'context-mode-toggle)这个键的作用是手动切换一次“启用/禁用”状态。当你临时进入一个特殊场景,比如远程桌面里的编辑 session,自动切换逻辑确实不适用时,按一下把自动切换关掉,干完再按一下恢复,不会有任何配置残留。