Cocoa Text Keybindings

2023-07-21

A version of this article appears on Jul. 21, 2023 on SSPAI as a member-only post. Learn more or subscribe

The article is permitted to be self-archived in the version as originally submitted for publication on the author’s personal website under CC BY-NC 4.0 pursuant to § 5.2(b) of the SSPAI Fellowship Contributor Agreement.

快,五秒钟内回答:macOS 和 Emacs 有什么共同点?

你可能说,太简单了,两个名字里都有一个 MAC?

没毛病……但考虑到本文的主题不是化妆品,我们还是看看苹果自己的回答

[macOS 的] 文本系统具有一套通用的组合键机制,完全可由用户重新设定,[…] 标准组合键包含大量与 Emacs 兼容的 Control 组合键 […]

如果你是 macOS 的老用户,可能已经知道这是在说什么:在 macOS 上编辑文本时,除了人尽皆知的 Command-C/V/X/A 等等,系统还支持一整套涵盖了光标移动、文本选择、编辑插入等功能的组合键(keybindings),其中很多来源于经典编辑器 Emacs,并且从八九十年代的 macOS 前身 NeXTSTEP 一直延续至今。

45 年前的 Emacs 使用手册,其中很多在 macOS 中可以直接使用

45 年前的 Emacs 使用手册,其中很多在 macOS 中可以直接使用

初试身手

那么,这些组合键都能做什么,相比于如今更常用的版本有什么优势呢?下面这张动图可以帮你建立一个初步印象:

不难看出,这些组合键的特点是都以 Control 键为基础,而且虽然涉及很多移动光标操作,但都没有用到任何方向键。因此,掌握熟练之后,可以省去很多移动手腕去摸鼠标和方向键的功夫,减少疲劳并提高编辑效率。


插曲:交换 Control 和 Caps Lock 键

在继续跟随后文上手之前,我非常建议通过系统设置把 Control 和 Caps Lock 键交换位置。需要承认,这肯定不是一个特别大众的设置方法,但据我观察不乏拥趸,而且用过的很少不说好。

为什么建议这么改?如你应该已经发现的那样,Cocoa 文本组合键重度依赖 Control。因此,如果你习惯使用这套组合键,将 Control 键放在 Caps Lock 键的位置,比每次都伸出「兰花指」去键盘左下角的原键位要舒服得多。事实上,这也是早期 IBM 电脑用过的经典布局,后来又被 HHKB 等品牌沿用——甚至苹果的官方支持都将它作为改法示例。

要修改这个设置,首先打开「系统设置」的「键盘」部分,点按右侧的「键盘快捷键」,然后选择左侧列表中的「修饰键」。在弹出的面板中,先确认上方选择的是当前使用的键盘,然后将 Control 键和 Caps Lock 键的操作分别改为对方即可。


乍看起来,使用这些组合键要记一堆字母,有一定学习门槛。但只要掌握了规律,记起来其实是很快的:

首先,大多操作都是「Control-字母键」的组合,其中的字母基本就是操作对应的单词首字母:向后(backward)、向前(forward)、上一行(previous)、下一行(next)、行尾(end)、删除(delete)、交换前后字符(transpose)、插入换行并使光标留在原地(open-line)等。一个例外是移动到段首的 Control-A,据当事人回忆,这只是因为……A 是字母表之首而已。很多历史就是这么任性。

其次,更复杂的组合键主要是在移动操作的基础上演变而来:加上 Shift,就变成了移动光标并选择;加上 Option,就变成了以单词为单位移动(甚至支持基础的中文分词)。如果同时加上 Option 和 Shift?当然就是以单词为单位移动并选择。

一组比较有年代感、可能需要额外解释的操作是 Control-K 和 Control-Y。K 的意思是 kill,功能是从当前光标处删除到段尾,并把删除的内容暂存到一个称为 kill ring 的容器中;Y 的意思是 yank,把之前 kill 的东西「抽」出来,放回当前光标位置。(如果连续按多次 Control-K 后再按 Control-Y,之前吃掉的多行会被合并在一起放出来。)

抛开这对莫名其妙的术语——上世纪七八十年代黑客有些独特的脑回路是可以理解的——可以姑且将其理解成独立于系统剪贴板、只适用于当前窗口的特殊剪切和粘贴。对于长文和代码编辑场景,这种从一行中间往后剪切、同时又不挤占剪贴板的能力是很实用的。

追本溯源

当然,即使你之前完全没有听说过这些组合键,这也不是你的问题。

一方面,它们确实年代久远、讨论不多;另一方面,macOS 也几乎没有给它们提供任何「曝光」的机会:不仅没有在菜单栏中列举,连「系统设置」中的快捷键设置都难觅踪影,唯一比较正式的提及也藏在官方「Mac 键盘快捷键」列表的深处,可能主要只有 Emacs 的真爱粉能通过肌肉记忆偶然发现。

但就算这套组合键再隐晦,既然存在于系统中,它总得有个来头,也总得在什么地方留下些痕迹吧?

答案是肯定的。操作系统的主要任务之一就是为应用程序做好各种「幕后工作」。而这些幕后工作中,很重要的一项就是文本处理,包括字符显示、格式排版、文本编辑等等。

在 macOS 中,负责文本处理的组件称为「Cocoa 文本系统」(Cocoa text system),是 Cocoa(macOS 原生应用 API)的一部分。本文介绍的这套 Emacs 风格组合键,就是由 Cocoa 文本系统负责响应的。

至于系统默认的组合键,上面介绍的只是冰山一角,完整的「目录」位于 /System/Library/Frameworks/AppKit.framework/Resources/StandardKeyBinding.dict。这是一个二进制编码的属性列表(plist)文件,本身不方便阅读。如果你安装了 Xcode,可以直接双击打开查看内容。或者,也可以运行:

plutil -convert json -o - /System/Library/Frameworks/AppKit.framework/Resources/StandardKeyBinding.dict | jq --ascii-output --sort-keys . > StandardKeyBinding.dict

其中,plutil 将原文件转化为 JSON 格式,打印到标准输出;jq(需要安装)转换其中的一些特殊字符为 Unicode 码位,并按键名排序;最后写入当前目录。(想偷懒可以直接看我保存的结果。)

StandardKeyBinding.dict 片段

这里乱七八糟的符号有点多,但仍然有章法。大致的格式是:每个组合键用一个键—值对表示,键名是指定物理按键的字符串,值是一个数组,表示按下该组合键时要调用的一个或一组操作(称为「选择器」[selector])。

其中,表示物理按键的字符主要是:

符号 含义
^ Control
~ Option
$ Shift
@ Command
小写字母 对应字母键本身
大写字母 Shift 加对应字母键
\u 开头的 Unicode 码位序列 对应的 ASCII 控制字符按键苹果的私有保留码位按键

更完整的说明可参见 Jacob Rus 最早写于 2006 年的指南;事实上,Rus 此文基本是网络上所有介绍 Cocoa 文本组合键文章的共同参考资料。

至于按键要对应的操作,则都是驼峰拼写、冒号结尾的方法,命名均来自 AppKit 中负责响应输入的 NSResponder(有个文档,虽然内容少到等于没有);但其实一般用户并不需要关注这些开发上的细节,看单词就足以猜出大部分意思。

照此「翻译」,我们就得知了所有 macOS 中「隐藏」的 Cocoa 文本组合键(排除了一些过于常见、没有实际功能和现代 Mac 上找不到的组合键):

一些补充说明:

  • 「前」(forward)、「后」(backward)是相对于书写方向而言。例如对于中英文,向前是指向右,向后是指向左。
  • 如未附加具体单位,「前」「后」「左」「右」均指一个字符,「上」「下」均指一行。
  • 「段」和「行」是两个不同概念。如果开启了按窗口边界/字符边界换行,一段可能在视觉上跨越多行(soft wrap),此时以「行」为单位的操作(例如 Control-N/P 和方向键)会在这些视觉分行之间移动,而以「段」为单位的操作(例如 Control-A/E/K)则会跳过这些视觉分行,只考虑以回车开启的「硬换行」(hard wrap)。
  • 「删除」操作均以当前光标位置为起点,并包含所选中的部分。
  • 「忽略输入框限制」(ignore field editor)是指对于表单等输入框,将 Enter 键、Tab 等按键视作字面含义,即只插入换行符、制表符等字符,不触发确认、切换到下一栏等特殊操作。

更完整 Cocoa 文本操作整理仍然可参考 Rus 的列表。从中也可以看出,这些操作几乎涵盖了文本编辑的方方面面:除了插入、删除、剪切、大小写转换、翻页、选中等通用文本操作,还包括针对富文本格的字体、样式和版式操作,甚至还有保存和关闭文档、调节窗口位置和大小等针对文本编辑环境的操作;系统内置的组合键只用到其中很小一部分。


插曲:自动重复多次组合键

Vim 用户一定很喜欢它用「数字 + 操作键」重复多次操作的功能,例如 10j 就可以下移光标 10 次,5dw 就可以向前删除 5 个单词等等。

对此,Cocoa 文本系统表示……我也行。但需要做一个设置。打开终端,执行:

defaults write -g NSRepeatCountBinding -string "^r"

然后重新登录让修改生效,就可以启用重复组合键功能,并将 Control-R 设置为引导组合键(也可以自己指定,语法如上文所述)。以后,只要先按下 Control-R,然后按下需要重复的次数,最后按要重复的组合键,就可以连续「开火」了。

在下面的例子中,我们使用 Control-R 引导,分别重复了 5 次 Control-N(向下一行)和 10 次 Control-K(删除到行尾)操作。


改出花样

既然默认组合键是以配置文件的形式储存的,一个自然的想法就是:能不能自定义这些组合键配置呢?

当然可以。正如苹果在文档中所说,用户可以通过在 ~/Library/KeyBindings/ 创建一个名为 DefaultKeyBinding.dict 的 plist 文件,自定义新的组合键(或者覆盖自带组合键)。

劝退说在前:我并不建议花很多时间去折腾 Cocoa 文本组合键,只推荐应该优先记住一些用着顺手的默认组合。原因在于,Cocoa 文本组合键本身有很多限制,特别是它在系统中的优先级过低。

怎么个低法?当用户按下一个组合键(称为一个「按键事件」)时,操作系统会按照一定的层级顺序决定由谁来响应这个「按键事件」。在 macOS 中,这个顺序一般是 (1) 操作系统全局、(2) 当前应用程序、(3) 当前窗口和 (4) 文本视图。

Key-event processing

Key-event processing

Cocoa 文本系统管理的只是最后一层文本视图。所以,只有当一个组合键在前三个层级都没有收到响应时,才轮得到它出场。相反,如果同一个组合键在这个流程中被「捷足先登」,也就没有 Cocoa 文本组合键什么事了。

这就是为什么前文一直在用略显老气的「文本编辑」app 做演示:这是 macOS 中最经典、最标准的 Cocoa 文本编辑环境,可能没有之一。很多更现代的文本编辑工具要么使用了非标准、非原生的文本视图,要么自定义了很多与默认设置冲突的文本编辑组合键,这都会导致 Cocoa 文本组合键没有用武之地。

除此之外,Cocoa 文本组合键还有设置麻烦、不能同步、与中文输入法兼容性不佳等缺陷;与 Emacs Lisp、VimScript 等更完善的配置语法相比,它缺少条件判断等基础能力,很难实现什么特别强大的效果。

当然,提这些缺陷并不是为了完全否认自定义 Cocoa 文本组合键的价值,只是为了帮助读者确立正确的的效果预期,以及找到合适的使用场景。实际上,包括 Safari 和 Chrome 中大多数网页输入框在内的常见位置,Cocoa 文本组合键都是可用的,做一些轻度配置能很好地提升输入效率。

言归正传,用来设置自定义组合键的 ~/Library/KeyBindings/DefaultKeyBinding.dict 可以使用 XML 或者 NextSTEP 语法;考虑到 XML 的语法过于啰嗦,实践中一般用后者。

plist 语法的介绍超出了本文范围。粗略地说,NeXTSTEP 格式 plist 跟 JSON 比较像,主要差别就是键值对之间用等号 = 而不是冒号 : 隔开、行尾不用逗号 , 而用分号 ;,数组用圆括号 () 而不是方括号 []

例如,下面这两行配置将 Control-Option-U 和 Control-Option-Shift-U 分别绑定到「修改单词为小写」和「修改单词为大写」操作上:

"^~u" = (lowercaseWord:);
"^~U" = (uppercaseWord:);

注意:

  • 整个配置文件要包裹在一个词典对象里,即开头和结尾须有成对的 {},这里略去未写,下同。)
  • 每次修改完成后,要将应用程序退出重开才能读取到新的设置。如果要在系统全局应用新设置,可以登出当前用户再登录。

如果自己设置的组合键与系统默认的相同,则会覆盖掉系统默认值。 例如,上面提到 Control-Y 默认绑定到 yank: 操作上,即从 kill ring 中放回之前删除的内容。但其实 kill ring 的容量是可以通过设置扩大的,从而保存多段文本。

为此,首先需要在终端执行:

defaults write -g NSTextKillRingSize -int 5

从而将 kill ring 的容量变成 5 段文本(可以随意改成其他数字)。

然后在 DefaultKeyBinding.dict 中添加一行:

"^y" = (yankAndSelect:);

从而将 Control-Y 重新绑定到 yankAndSelect: 操作上,也就是每次放回文本后,自动选择 kill ring 中保存的下一段文本作为下次放回的对象。(这也意味着 kill ring 的工作机制是像堆栈那样「先进后出」的。)

在下面这个例子中,我们通过 kill ring 实现了倒转列表:

值得一提,一个 Cocoa 文本组合键并不是只能执行一个操作,而是可以触发一连串操作。

例如,如果想获得一个「Markdown 加粗」——本质上是在所选文本两侧插入 ``——那么可以添加下面这行配置,将其绑定到 Command-Option-B 组合键上:

"@~b" = (setMark:, swapWithMark:, deleteToMark:, insertText:, "", yank:, insertText:, "");

让我们来分析一下这组操作都做了些什么:

首先,通过 setMark: 「标记」(mark)当前选区。「标记」也是一个沿用自 Emacs 的概念,相当于一个额外的隐藏选区(selection);并且和选区一样,标记的长度是可变的,当长度为 0 时,标记(或选区)就相当于收缩成一个光标(插入点)。

然后,swapWithMark: 将当前的选区和标记相互对换,deleteToMark: 删除选区、标记的范围以及任何夹在两者的内容,并将其暂存到 kill ring。

回顾一下:通过目前为止这几步,我们获取了待加粗部分的内容,并将其暂时删除备用。(如果你好奇为什么不直接用 delete::这是为了兼顾没有选中任何内容的情况。如果直接用 delete:,由于其没有删除任何内容,会导致后续的 yank: 从 kill ring 中取出之前保存的内容。上述几步保证了即使没有选中内容,kill ring 也会被清空。)

接下来的任务就很简单了:insertText: 插入开头的加粗标记,yank: 放回刚才删除暂存的待加粗内容,再次用 insertText: 插入结尾的加粗标记。这就实现了将待加粗内容夹在一对加粗标记之间。

以此类推,将上面 insertText: 插入的内容从 ** 改为 *,就可以得到「设置为 Markdown 斜体」;改成 ,就可以得到「将所选内容放入中文直角引号」。

此外,Cocoa 文本组合键并不一定需要同时按下,而是可以设计成「多段」的。

例如,Emacs 用户都熟悉一组以 C-x(Control-X)为前缀的组合键:C-x u(按下 Control-X 后松手,再按 U)是撤销,C-x k 是关闭 buffer(大致相当于文档)等。

如果要在 macOS 中复刻这些组合键,可以在配置中写:

"^x" = {
    "^u" = "undo:";
    "^k" = "performClose:";
};

注意这里相比于原版,第二步的按键都额外加上了 Control。这是因为单字母键在中文输入法下是没有用的,最好把每一步都设置成带修饰键的组合,回避这个问题。

最后,如果从头写一套配置文件对你来说太麻烦了,可以看看 Brett Terpstra(著名 Markdown 预览工具 Marked 的作者)长期以来维护并分享的定制方案,其中的组合键数量多达上百个。

Brett Terpstra 的配置片段

不过,本文并不建议读者直接导入 Terpstra 的方案,因为如果不是自己通过长期使用形成反射记忆,一股脑设置这么多组合键也很难发挥效率,反而可能与其他工具的快捷键冲突;此外,Terpstra 的很多实现方式也不适合中文输入的实际情况。但作为一个范本和灵感来源,这套注释完善的配置是很值得一看的。