☰
Zed / GPUI 设计觉醒:现代编辑器 Action 机制解读
2026/10/9 5:41:07 网站建设 项目流程

文章目录

  • 1. Event 响应机制简介
  • 2. Action 响应机制简介
    • 2.1 将 Entity 纳入焦点系统
    • 2.2 注册并响应 Action
  • 3. 人机交互和约定用语
  • 4. Event 响应流程推导
  • 5. Action 响应流程推导
  • 6. Action 的设计定位
  • 7. Context:KeyBinding 的“适用条件”
  • 8. 解答几个困惑

我花了两周时间才参透了 Zed / GPUI 的 Action 设计,这是一个非常抽象且背景复杂的课题,攻克的过程一言难尽。由于缺少系统性介绍 GPUI 的文章和书籍,除了阅读源码,我也不得不借助 AI 辅助理解一些复杂的问题。但是,在这个问题上 AI 的表现非常糟糕,在几个关键问题上都给出了完全错误的解释。GPUI 的 Action 机制是在一种特定背景下提出的解决方案,这个背景隐藏在交互模式之下的复杂逻辑中,很难被识别出来。从开发人员的角度看,完成一个 Action 的注册和响应与完成一个 Event 的注册和响应在代码上几乎是一样的,完全体会不出两者的差别,而 Action 的应用场景也令人费解,它通常用于响应键盘输入,但在点击菜单和某些按钮时也会使用它。对于熟悉 Qt 的开发者来说,则会更加困惑,因为在 Qt 中也有一种 Action 的实现叫 QAction,Qt 的 QAction 是一个看得见、摸得着的 Action,理解起来舒适而自然,这也影响了很多开发人员对 Action 的认知,形成了先入为主的印象,对准确理解 GPUI 的 Action 造成了干扰。本文,我们就把 GPUI 的 Action 机制彻底阐述清楚,而介绍 Action 机制离不开与它相对应的 Event 机制,所以,我们会将两者放在一起对比着介绍,这能让大家更清晰地看到 Action 所要解决的独特问题。

1. Event 响应机制简介

基于 Event 的响应机制是每个 GUI 框架都会提供的核心功能,通常它被认为是:面向鼠标输入的响应模式。鼠标输入有一个显著的特点:抓住对象,直接操作。当我们看到一个按钮可以直接点击它,看到分割条可以拖动它,这些在今天看来稀松平常的操作,也曾是计算机早期发展历程中,在交互模式上的一次重大创新,在学术领域有专门的理论,叫 Direct Manipulation:直接操作模式,它来自于 Ben Shneiderman 在 1983 年撰写的一篇论文《Direct Manipulation: A Step Beyond Programming Languages》,这一模式深刻影响了后来的 GUI 应用,我们今天所谓的“基于 Event 的响应模式”就是从这里发展而来,之所以要回溯这段历史,是要让大家记住基于 Event 的响应模式的本质特征:操作和操作对象一起产生。

在《Rust GPUI 桌面应用开发入门:界面交互和事件响应》一文中给出过一个 Counter 示例,它完整地展示了一个 UI 组件响应鼠标单击事件的全部编码工作:

①在需要接收鼠标点击事件的 UI 元素上使用on_mouse_up()注册要监听的 Event 以及对应的响应函数。其中,方法的第一个参数是一个鼠标按键类型:MouseButton::Left,表示鼠标左键,它不是 Event ,MouseUpEvent 才是 Event。注册时,只需设定响应什么按键,无需使用 Event 来注册;在分发事件时,GPUI 会将事件中的按键和此处提供的按键进行匹配,即:event.button == button 以确定当前函数是否是对应的响应函数,所以这个“鼠标左键”参数相当于一个过滤条件。第二个参数叫 listener,其实这里需要的是一个函数,函数的参数和类型都通过类型约束定义好了,开发者需要在某处(一般是 Entity 的关联方法)实现这个响应函数,然后以函数项的形式传入,关于此处的 Rust 语法知识,可参考《一团乱麻?带你厘清 Rust 中的函数指针、函数项、fn 类型、Fn Trait》

②提供一个用于响应事件的响应函数,通常,这个函数会定义为某个 Entity 的关联函数。受到on_mouse_up()对第二个参数,也就是传入函数的类型约束,这个事件响应函数的签名是规定好的,主要是三个参数:鼠标事件、&mut window 和 cx,除了方法要实现的业务逻辑外,如果在处理过程中需要改动 UI 组件或应用状态,可以通过传入的 &mut window 和 cx 去操作。

2. Action 响应机制简介

如果说 Event 机制是面向鼠标操作的响应模式,那 Action 机制就是面向键盘操作的响应模式。不过,Action 机制在学术上并没有什么理论起源,它是在编辑器软件的迭代发展中被提炼和完善出来的。GPUI 的 Action 机制不单单是使用键盘下达操作指令,它更多的能力体现在使用配置文件进行配置以及通过 Context 设置更精准地按键触发条件。Context 是 Action 机制非常重要的组成部分,最早由 Sublime Text 提出并实现,VS Code 中也有类似的功能叫 when 子句,GPUI 参考并提升了这些软件的 Context 设计。同样的,我们以《Rust GPUI 桌面应用开发入门:界面交互和事件响应》一文中的 Counter 为例,看一下如何让一个 UI 元素去响应键盘输入。整体上可以分成两个相对独立的部分:将组件纳入焦点系统和注册 Action。

2.1 将 Entity 纳入焦点系统

将 Entity 纳入焦点系统是 Action 响应机制的前置条件。实际上,伴随着本文后续的介绍,你会意识到,这并不是什么前置条件,而是 Action 响应机制中必须的一环,因为键盘输入的操作对象是靠焦点来确定的,如果 Entity 不在焦点系统中,就无法响应任何键盘输入。下图是 Counter 示例中完成 Entity 焦点注册的全部操作:

①在创建 Entity 时,需要植入一个焦点句柄,因为在第 ③ 步注册焦点时需要提供这个句柄。

②Entity 需要实现 Focusable 这个特质,因为 GPUI 框架在焦点路径上寻找可响应的组件时,是用 Focusable 作为类型约束的,如果一个组件没有实现 Focusable 则在获取焦点时会遇到麻烦。

③在 UI 元素上注册 Entity 的焦点句柄,让 GPUI 沿着 UI 树上的“焦点路径”查找可响应的 Entity 时能找到它。

④在初始化窗口时需要创建 Entity 实例,此时需要传入一个焦点句柄的实例给到它,这个焦点句柄实例一般是从 cx 处获得的。

2.2 注册并响应 Action

完成焦点注册后,才可以进行 Action 的注册和响应操作。下图是 Counter 示例进行 Action 注册和响应的全部操作:

①首先使用actions!宏声明所需的 Action。我们知道 Action 代表用户的操作意图,所以,Action 的命名要能体现出明确的“目的性”。

②在需要响应 Action 的 UI 元素上使用on_action()注册响应函数。这里也不需要在参数中指定监听什么 Action,因为 GPUI 能根据注册的响应函数的第一个参数的类型自动推导出对应的 Action 类型。

③实现响应函数。通常,这个函数会定义为某个 Entity 的关联函数。跟事件响应函数一样,它们的函数签名也是被on_action()对参数的类型约束规定好的,也是三个:action、&mut window 和 cx。如果在接收到 Action 后需要获得更多环境信息,可以通过 &mut window 和 cx 获取。

以上,只是触发 Action 的一种途径,Action 的一个重要功能是:可以通过多种不同的途径触发同一个 Action。以 Reset 这个 Action 为例,在 Counter 示例中,我们还可以通过按下r键来触发它,而实现方法也是 3 步,和上面介绍的在 UI 元素上点击的实现整体上是一样的,只不过,在第二步时,Action 不是注册在 UI 组件上,而是绑定到了按键上,具体如下 :

①同上

②绑定按键要通过 cx 进行。Action 的捕获要依靠焦点系统,所以如本节开头所说:要让 Action 生效,必须让组件(Entity)先具备获取焦点的资格。

③同上

3. 人机交互和约定用语

接下来,我们要分别将 Event 和 Action 的响应流程推导一遍,这是找出 Action 设计定位的必要一环,但在这之前,需要储备一点人机交互的理论知识,同时对后续论述中用到的一些表述做一些约定,以确保全文一致。

使用鼠标和键盘向软件下达操作指令是我们非常熟悉的日常,但是,我相信绝大多数人都没有认真思考过这个过程是怎样发生的,这其实是计算机领域里的一个重要学科,叫:人机交互(HCI),这里面其实有很多学问,有一些是我们没有意识到但对理解 Event 和 Action 机制非常重要的因素。

在交互过程中,人与计算机之间存在通信,通常涉用两种语言:用户到计算机的方向涉及各种交互设备;而计算机到用户的方向主要通过显示器传递给人的眼睛,当然也可能包含声音或触觉等组成部分。这两种语言各自的意义(meaning)和形式(form)构成了自然的抽象边界:我们必须确定用户可以向计算机传达什么信息(意义),以及每种信息通过什么方式来传达(形式);反过来也同样如此。此外,还有第三个组成部分:交互设备与显示器之间(其实就是 GUI 应用/框架内的处理逻辑)的关系,或者说,将输入转换为输出中某种有意义内容所需要的数学方法或算法。

以上引用自《Computer Graphics: Principles and Practice》 第 21.2 节,我们讨论的 Event 和 Action 响应机制就会涉及“用户 🠚 计算机”和“交互设备 🠚 GUI 应用”这两个层面。

其中,在“用户 🠚 计算机”这个层面发生的事情可以简单概括为:用户通过鼠标和键盘两种输入设备将操作和操作对象输入给计算机,然后计算机在内部(也就是 GUI 应用内部)找到“操作对象”然后执行对应的“操作”。这里,我们正式提出“操作”和“操作对象”这两个术语,与“操作”含义接近的表述还有:命令、指令,与“操作对象”含义接近的表述还有:目标、对象等,如果你有自己的习惯用语,请在此自动对齐,后续我们将统一使用“操作”和“操作对象”这两个术语。

其实,人通过输入设备跟一个计算机软件进行交互是一个很抽象的过程,这个过程要将人的“意图”转换成应用软件中的一个“响应方法”,这个过程经不住细想,想得越多往往就越迷惑。在人机交互理论中,针对这个问题的解释有一个著名的四层模型理论,是由 Foley 和 Van Dam 在 1982 年出版的 《The Design of User-Computer Graphic Conversations》一书中提出(寻找原始出处的过程犹如一场计算机科学考古,是 AI 把我引入了歧途):

它的核心思想是:把人机交互从高层用户意图到底层设备输入拆成“概念层 🠚 语义层 🠚 语法层 🠚 词法层”四个独立层级,每层只负责一类问题,实现关注点分离。不过,这并不是我们理解 Event 和 Action 响应机制的一个必要知识点,实际上,我们可以用下面这种更通俗的方式来理解鼠标和键盘的交互:

  • 在鼠标交互中:输入 = 操作 + 操作对象

    • 操作对象:

      在界面上时指的是用户点击的那个 “UI 组件”;

      在应用中时指的是那个 UI 组件在内存中的 “对象”;

    • 操作:

      在界面上是时指用户的 “点击鼠标”;

      在应用中时是指内存中操作对象上的一个 “方法”;

  • 在键盘交互中:输入 = 操作

    • 操作对象(并不通过按键指定,而是由焦点提供):

      在界面上时指的是焦点停留的那个 “UI 组件”;

      在应用中时指的是焦点停留的那个 UI 组件在内存中的 “对象”;

    • 操作:

      在界面上是时指用户 “按下组合键”;

      在应用中时是指内存中操作对象上的一个 “方法”;

关于操作在应用中的映射物:内存中操作对象上的“方法”,如果是直白的设计,那它就应该是一个 UI 组件的成员方法,然后由开发人员根据操作意图实现里面的逻辑,不过,很多 GUI 框架都会优化这一部分的设计,通过事件委托模型(一种设计模式),将操作请求委派给另一个业务对象的方法去处理,此处,我们忽略这一细节,从逻辑上说,在应用中,操作就是操作对象上的一个方法。

4. Event 响应流程推导

从用户的操作意图上讲,当人点击一个按钮时,就是希望它去执行一个操作,这是“按钮”这种 UI 组件的领域职责,人们引入按钮这种组件时就是这样设想的,所以,一个按钮 = 一项操作,点击一个按钮 = 执行一项操作。当一个按钮被按下时,它应该天然得知道自己要做什么,所以,按钮要有一个方法,这个方法里的逻辑就是“它应该知道要去做的事”,也就是“操作”的具体实现,不管是用领域驱动设计还是朴素的面向对象建模,都会得出一样的结论。在这样的逻辑驱动下,基于 Event 的响应过程大致是这样的:我们以《Rust GPUI 桌面应用开发入门:界面交互和事件响应》一文中的“点击 Reset 按钮”这个操作为例,在按下鼠标键的那一刻,会产生两个物理信号:

1.物理信号:鼠标左键点击

2.物理信号:鼠标指针坐标

基于鼠标的交互有一个显著的特点:输入会携带操作对象,通过鼠标指针坐标,GUI 框架可以确定点击的是哪一个 UI 组件。这一交互特点对 Event 机制产生了深刻的影响,因为被指向的组件就是用户意图的“执行者”,用户之所以“指”它,就是要让它来执行操作,这直接决定了 GUI 框架必须在程序内存中找到被指的对象。

当两个物理信号输入操作系统,再进入到应用中后。GUI 框架的任务是:解读这些输入信息然后转换成一次函数调用。处理和解读这些输入信息的工作其实非常重要,包含坐标转换、事件合并、命中测试、遍历 UI 树等一系列工作。其中,命中测试是一个非常重要的环节:人点击一个按钮,在现实世界中是一个很直观的操作,但输入到计算中的只是”鼠标左键点击“和”鼠标指针坐标“这种物理信号,GUI 框架需要根据这些物理信号确定是哪一个“按钮”被点击了,这里的“按钮”指的是位于 GUI 应用内存中的“按钮对象”,找到这个对象几乎是整个事件响应流程中最关键的一个环节。GUI 使用的定位方法叫命中测试(Hit-Testing),它会基于点击时鼠标指针的坐标和应用窗口的尺寸与位置进行几何计算,从而判断出点在了哪个对象上。经过 GUI 框架处理后,原始的输入信息被转换成了内存中两个确定的对象:

1.内存对象:Event(它是 GUI 框架基于按键、坐标等输入信息封装而来)

2.内存对象:Button(它是 GUI 框架基于输入信息进行命中测试找到的被点击按钮在应用内存中的形态)

然后,GUI 框架会根据 Event 在 Button 上找到与之对应的响应函数,然后调用它,整个事件响应流程就结束了。但是,大多数 GUI 框架都不会直接这样实现,因为,这个响应函数显然是要由用户来编写的,而 UI 组件一般是 GUI 库中的固化类型,一方面用户很难直接在上面添加或重写一个方法,另一方面,即使可以这样做,也不适合在 UI 组件里编写业务代码,于是很多 GUI 框架选择了一种设计模式:事件委托模型(Event‑Delegation Model),它是观察者模式的一种改良版本,它不需要事件源自己亲自处理事件,而是把事件 “委托” 给注册的监听器对象处理,其实就是一种回调机制。所以,完整的响应流程如下图所示:


由于 GUI 中的很多处理工作都较为底层,且无需用户干预,所以,它们会被框架封装起来,对用户不可见。用户如果缺失了对这一部分工作的了解,就很容易对 Event 模式产生困惑。其中最常见的一个困惑就是:作为这个模式下领域建模的核心业务实体:Event 的存在感往往很弱,在编程时人们很少会用到它,大多数情况下,它的用处是在注册响应函数时作为一个 key 出现,以便区分同一个 UI 组件对应不同输入(mouse left up / left down / …)的响应方法是哪一个。导致这种奇怪现象的原因是:人们只看到了 Event 这个“结果”,并没有感受到产生 Event 的“过程”,在见到 Event 之前,GUI 框架已经完成了大量的后台工作,作为框架的终端用户很难意识到这一点,我们可以把这些在背后发挥作用的功能称为:隐式全局状态 & 机制。这些幕后工作并不是单纯为了生成一个 Event,更重大的意义在于通过这个 Event 可以找到与之对应的响应函数并执行它,这才是 Event 的意义所在。

作为事件委托模型的一环,Event 和响应函数是通过注册才关联在一起的,这会发生在on_mouse_up()这类注册方法上(GPUI 使用的是鼠标按键注册,而非 Event,两者作为 Key 是没有差别的),这一步操作的效果就是将操作对象和最终的响应函数通过 Event 关联起来,在 UML 中,Event 是一个典型的 Qualifier:

5. Action 响应流程推导

我们再来推导 Action 的响应机制,同样从用户操作意图着手:当人按下一个组合键时,也是期望执行一个操作,这个操作的内涵是非常确定的,比如 Ctrl-S 就是要”保存“,但问题是:按键无法指向一个”操作对象“,GUI 框架不知道按下 Ctrl-S 要保存“谁”?这就是基于键盘的交互和基于鼠标的交互之间最根本的差异。没有操作对象,这种交互还可行吗?可行!因为在键盘交互中有一类操作不需要操作对象,或者说整个应用是操作对象,例如:打开设置对话框、切换主题等;而更普遍的是另一种情形:键盘输入存在一个天然的”默认操作对象“,就是“刚刚还在操作的那个对象”。

在用户的意识里,连续的界面操作会产生一个很短的上下文(人的记忆力和注意力有限,这个上下文注定不会太长),当人们在 IDE 的编辑窗口中编写完代码,按下 Ctrl-S 时,人们想表达的是:保存当前的这段文本;当人们打开 IDE(以 Zed 为例) 的工程面板,按下 Ctrl-Right 时,人们想表达的就是:把当前工程目录下的所有文件夹全部展开。这些操作都不需要指明操作对象,因为在用户的意识中:我当前正在停留的编辑器或工程面板,就是我下达操作指令时针对的“操作对象”。

而在 GUI 框架中,有一种机制能完美地追踪和表达用户意识里的这种“上下文”,它就是“焦点”!焦点会跟随人的操作自动迁移,与人们意识中的操作上下文高度一致,于是,焦点就自动为了键盘交互中不可或缺的参与方!这就是为什么使用 GPUI 的 Action 总是需要先注册焦点的原因!它有点像”被动技能“,一直存在且持续发挥着作用,但很容易被人忽略。有了焦点的参与,键盘输入就可以确定下“操作对象”了。

按键输入了“操作”,焦点确定了“操作对象”,那后续的处理流程就跟 Event 模式一致了,就是找到操作对象上的一个方法并执行它。我们以 Zed 为例,假设用户在 Editor 中按下 Ctrl-S 组合键保存文本,一个 Action 响应流程就开始了,在按下快捷键的那一刻,只会产生一个物理信号:

1.物理信号:Ctrl-S 组合键按下

相比于鼠标的点击操作,键盘操作的显著特点是:输入不携带操作对象,按键输入不含坐标信息,GUI 框架无法判断这个操作针对的是哪个 UI 组件。我们先看一下按键信息的处理,当这个信号经操作系统进入到 GUI 框架后,GUI(本节后面提到 GUI 时都是指 GPUI 了,因为是基于 GPUI 的实现讲解的) 需要先找到这个组合键代表什么操作,这个信息保存在一个叫 Keymap 的数据结构中,并且是可配置的,在 Keymap 中有若干个 KeyBinding,每一个 KeyBinding 描述的是一个“按键”(Keystroke)和一个 Action 的映射关系,GUI 框架就是通过查询 Keymap 找到按键对应的 Action 的,关于 Keymap 和 KeyBinding 会在后面的章节深入介绍。这里有一个非常关键的问题:我们一直在说:要寻找“操作”,而这里我们找到的是 Action,这两者是不同的,但我们不能在这里展开解释,现在还不是揭开 Action 面纱的适当时机,此时可以先将 Action 简单地理解为操作。与此同时,另一条独立的链路也在工作,那就是通过焦点确定操作对象,焦点是键盘交互中容易被人忽略的一个状态信息:

2.状态信息(隐式输入):焦点停留在 Editor 上,Editor 就是默认的操作对象

解释一个小小的背景,Zed 中有很多种编辑器,每一种编辑器可以视作一种独立的 UI 组件,Editor(纯文本编辑器)是其中的一种,并不是指整个应用。通过焦点确定操作对象的过程也不简单,因为有时候,焦点所在的 UI 组件未必注册过对应的 Action,这时,GPUI 会沿着组件(在 GPUI 中应该是 View)的树状层级关系向上查找,直至找到注册了对应 Action 的组件,这个机制叫“焦点冒泡”,这也符合人们有的习惯性理解,并且,它还能兼容我们前面提到的“不需要响应对象”的键盘命令,因为,当焦点冒泡到最上层时,就会由 cx 来兜底了,这类键盘命令都是通过cx.on_action()来注册的。

在确定了操作对象后,GUI 框架会根据 Action 找到 Editor 上对应的方法,然后调用它,这样,整个事件响应流程就结束了。跟 Event 类似,Action 的处理也使用事件委托模型进行优化。完整的响应流程如下图所示:


同样的,Action 和对应的响应函数也是通过注册才关联在一起的,这会发生在on_action()这类注册方法上,这一步操作的效果就是将操作对象和最终的响应函数通过 Action 关联起来,在 UML 中,Action 跟 Event 一样,都是典型的 Qualifier:

6. Action 的设计定位

对比 Action 和 Event 的响应机制不难发现:两者的整体框架其实基本一致,都是用一部分输入信息确定内存中的操作对象,用另一部分输入信息封装或映射成一种类型(Event 或 Action),作为 key 去确定具体的操作,最后执行它,如果说有什么不同,那就是 Action 机制下的操作对象是通过焦点确定的。但是,了解 Action 的处理流程并不能消解我们对 Action 的困惑,如果整体的处理框架一致,那为什么出现的是 Action 而不是叫 KeyboardEvent 一类的东西呢?想要参透 Action 的玄机,还要从头说起。

我们知道,Event 是对输入设备发出的物理信号的抽象和封装,它含有实实在在的输入信息,而 Action 则几乎是一个“空”的概念,它没有任何字段,这很难让人把它理解成一种具象化的事物。一种常见的解释是:Action 代表“用户意图”,但这个表述本身就很抽象,“意图”这种东西如何通过建模表示出来呢?所以,这个解释看似深刻,实则并没有切中要害。想要透彻理解 Action 就需要深入到 Action 的响应机制中,从上下文中揣摩它的作用。

由于鼠标输入自带坐标信息,天然绑定“操作对象”(操作和操作对象是一起提供的),这些信息进入到应用中后,GUI 框架可以通过几何命中测试找到应用内存中的“操作对象”,而“操作”天然地被映射成了这个对象上的一个“方法”(或再委派一个方法),所以,在内存中寻找操作就是寻找操作对象,它们是一条链路。但是,键盘输入只给出了“操作”,不会指明“操作对象”,操作对象是由焦点确定的,键盘输入的响应流程不可避免地被分割成两段独立的链路:“根据按键确定操作”和“根据焦点确定操作对象”,如下图所示:

┌─ 确定操作的链路 ─────────────────────┐ 按键 → │ keymap 查表 + context 筛选 → Action │ ← 全部可配置 └───────────────────┬────────────────┘ │ ┌─ 确定操作对象链路 ──┴─────────────────┐ 焦点 → │ dispatch_path → 找 action listener │ ← 全自动,不可配置 └─────────────────────────────────────┘

键盘交互导致的这种“操作和操作对象的被迫分离”,会带来一个不易被察觉的变化:“操作”不可避免地从“操作对象”上剥离了,不再依附于它,变成了一种能独立存在和表达的对象。那这会是 Action 产生的根本原因吗?我们继续向下分析。**当“操作”变成了一种需要独立存在和表达的对象时,就引入了一个关键命题:用什么来描述“操作”呢?必须是 Action 吗?没有别的方案吗?**这是解开 Action 设计谜题的一把关键钥匙。如果我们是 GUI 框架的设计者,在已经设计出了鼠标响应方案后,面对这个问题最先想到的方法应该是效仿 Event 机制,直接把按键封装成一种类型,比如叫 Keystroke(或 KeyboardEvent),那键盘的响应方式就跟鼠标响应模式完全对齐了,这是绝对可行的!也就是说:只用 Keystroke 这种类 Event 的类型完全可以处理键盘响应!并不需要什么 Action,所以,键盘输入导致的操作和操作对象的被迫分离,并不是引入 Action 的根本原因!

也许是出于某种心理暗示,我们一直怀疑产生 Action 的根源是键盘交互模式,但 Keystroke 方案正式宣告了这种想法的破产,没有 Action 键盘响应依旧可以实现。所以,我们一直寻找的答案未必是我们期待的答案,但却是最真实的:Action 就是一种纯纯的设计方案,并不是键盘交互模式下一种“注定”的机制!

如果你对这个结论还有所迟疑,那接下来的推导会打消你所有的困惑。我们说 Keystroke 方案可行,那它有什么问题吗?一个显而易见的短板是:它的按键和响应方法是绑死的,绑定发生在on_keystroke(....)方法上,这是程序代码,如果想修改映射必须修改代码并重新编译,这就不是用户能做到的了。但快捷键恰恰是需要可配置和可更改的,这与鼠标的按键不同,鼠标按键本身只有左、中、右三个键,且每一种按键的意图已经形成了约定的共识,没有定制化的诉求,而组合键的数量众多,用户需要根据自己的习惯和喜好定制,这就对 Keystroke 方案提出了要求:按键和响应方法不能绑死,必须可配置。

如果不想让按键跟响应方法绑死,那就得在配置文件中把它们的映射关系描述出来,这首先需要解决按键和响应方法如何用文本表示的问题,这对按键很容易,类似 Ctrl-S 这种书写形式足够了,但响应方法的文本表示就很困难了,你不能让用户去手写一个函数签名,即使你把所有的响应函数写在文档里供用户参考,这种配置方式也是极为原始和简陋的。一种优化思路是:给每一个响应方法起一个“别名”,这个别名既要能表达响应函数的功能或意图,又要便于书写(因为要出现在配置文件中),同时,为了防止用户填写错误,这些别名必须被类型化,而且值必须是可穷举的集合,也就是说它应该是枚举类型,把这些对函数别名的诉求集中起来,就可以提炼出了一种单独的类型,这就是 Action 的由来!

Action 的全部设计“心思”其实是:不让按键和操作对象产生直接关联,因为注册它们关联关系的地方是注册函数(on_keystroke(....)),如果直接关联,就必须硬编码,而如果在它们中间引入一个弹性的“中间角色”,这个角色必须要类型化、取值是有限的集合、书写成“字面量”要简单,让这个中间角色跟响应方法通过硬编码绑定,让按键和这个中间角色的映射变成可配置的,那问题就完美解决了!这是 Action 全部设计动机的来源!

7. Context:KeyBinding 的“适用条件”

在理解了 Action 的设计初衷之后,我们再来看看它的“周边生态”。前面曾提到过,GPUI 的 Action 机制并不仅仅指 Action 本身,还包括了与它的紧密相关的 Context 机制,这是让 Action 机制更加灵活强大的一个特性。那什么是 Context 呢?在配置一个按键(Keystroke)和 Action 的绑定时,我们会看到它:


如果是在配置文件中,它是这样的:

{"context":"Editor","bindings":{..."ctrl-alt-l":"editor::Format","ctrl-shift-f":"editor::Format","ctrl-c":"editor::Copy"...}}

其中,Keystroke 和 Action 组成的 1:1 关联关系叫:KeyBinding,而多个 KeyBinding 可以一起关联在一个 Context 上。Keystroke、Action、KeyBinding、Context 四者之间的关系是这样的:从代码层面上看,一个 KeyBinding 以字段形式包含 Keystroke、Action、Context:

┌──────────────────────────────────────────────────────┐ │ Keymap │ │ └─ Vec<KeyBinding> │ │ ├─ keystrokes : [KeybindingKeystroke] 1..* │ │ ├─ context_predicate : Option<...> 0..1 │ │ └─ action : Box<dyn Action> 1 │ └──────────────────────────────────────────────────────┘

而从配置文件上看,Context 是跟 KeyBinding(Keystroke + Action)保持关联的,它们之间是一对多关系,即:一个 Context 会对应多个 KeyBinding,所以,它们的类间关系是这样的:

如果一个 KeyBinding 关联了一个 Context,它将与 Zed 当前活动的上下文(其实是焦点链路)进行匹配,这个上下文(焦点链路)是一个 UI 树,根为 Workspace,里面包含 Pane 和 Dock,Pane 会包含 Editor、Markdown 等子组件,Dock 会包含 Project Panel、Outline Panel 等。为了在配置时让用户知道所处的 Context 是什么,Zed 提供了一个查看当前 Context 的工具,在命令面板中使用dev: open key context view命令即可打开。

Context 这个称谓有一定的误导性,并不能很好的表示它的定位和功能,像 Workspace、Pane、Edidor 这样会获得焦点的组件是比较直观的 Context 示例,但却不是全部,像Editor && showing_completions这样带有状态判断的示例反而是更标准的 Context。实际上,Context 在代码中的对应类型是:

pubenumKeyBindingContextPredicate{Identifier(SharedString),Equal(...),NotEqual(...),Descendant(...),Not(...),And(...),Or(...)}

在 Context 中出现的 Workspace、Pane、Edidor 都是 Identifier,而各种逻辑运算符和状态描述也是它的一部分,可以出现在 Context 中的逻辑运算符包括:

  • X && Y两个条件须同时满足
  • X || Y两个条件满足其一即可
  • !X不满足某个条件才算匹配
  • (X)用于分组
  • X > Y当树中的祖先匹配 X 且此层匹配 Y 时才算匹配

例如:

  • "context": "Editor"- 匹配任何编辑器
  • "context": "Editor && mode == full"- 匹配用于编辑代码的主编辑器
  • "context": "!Editor && !Terminal"- 匹配任何地方,除了焦点在 Editor 或 Terminal 的情况
  • "context": "os == macos > Editor"- 匹配在 macOS 上的任何编辑器

Identifier + 逻辑运算符 + 状态描述才是一个完整的 Context,也就是说,Context 其实是一种判定条件,支持 and、or 等逻辑运算,过去,一个按键和一个 Action 是否匹配只取决于它们之间有没有映射关系,而 Context 则可以在匹配的基础上追加了更多更细致的限定条件(尤其是组件的一些状态信息),通过这些更细致的条件刻画,可以让键盘输入应对更复杂、更特定的场景,本质上是提升了键盘输入的“精确度”。怎么理解”精确度“这种表述呢?

作为一个文本编辑器,Zed 极为倚重键盘输入,所以,Zed 制定了大量的 Action(完整的 Action 列表:https://zed.dev/docs/all-actions?highlight=action#all-actions),Keystroke 和 Action 的绑定是一种有或无的关系,现实中,有很多绑定并不是有无的问题,而是只会在特定条件下触发,人们必须在两者的绑定关系上设置适用条件。举个例子:当我们在 Editor 中编写代码时,输入第一个字符时就会弹出“自动完成提示窗口”,这时,我们可以继续输入,直至出现我们需要的内容,例如一个完整的变量名或函数名等,此时按下回车键,想要书写的内容就自动完成了,在这个场景中,回车键绑定的是一个叫editor: confirm completion的 Action,但显然这个绑定是有触发条件的:它只能在“弹出自动完成提示窗口”的前提下触发,否则就会和普通文本输入时的回车键冲突了,所以它的 Context 定义必须是Editor && showing_completions,这个示例解释了“精确度”这种表述,同时也揭示了引入 Context 的必要性。

8. 解答几个困惑

完整地梳理了 Action 机制的来龙去脉后,我们终于可以轻松地把过去的一些困惑解释清楚了。

1.为什么菜单和部分按钮会使用 Action 机制响应?

在 GPUI 中,一个 MenuItem 会直接关联一个 Action,而不是响应鼠标点击事件,为什么菜单的响应会用 Action 的方式处理呢?因为菜单是典型的“命令”,也就是可独立执行的“操作”,天然不依赖操作对象,或者说操作对象是整个应用。在 Zed 中,有很多此类的 Action,它们都被放进了 menu 这个命名空间,而且都不会配置 Context,也就是说 Context 是 global:

如果一个 KeyBinding 未设置 Context,那它就是全局有效(在 keymap 配置页面中的 context 会显示为 global),这时,它就是标准的 Menu Action!而实际上 Zed 中大量的菜单项对应的都是此类 Action。

而一些按钮也会选择使用 Action 响应,具体的操作方法是:在按钮的响应函数中使用window.dispatch_action()再次触发一个 Action,然后走 Action 的处理流程,这种操作在 Zed 中非常普遍,就像这样:

// crates/title_bar/src/update_version.rs:131.on_click(cx.listener(|_,_,window,cx|{window.dispatch_action(Box::new(workspace::OpenLog),cx)}))

为什么按钮也会使用这样的处理方式呢?很简单,因为按钮需要响应的操作已经在某个 Action 的响应函数中实现了,按钮没有必要再重复实现一遍,直接复用就可以了。基于 Action 实现的响应机制可以通过代码发起,这大大方便了它的复用。

2.Zed 的 Action 与 Qt 的 QAction 同为 Action 机制,为什么会如此的不同?而且反而是 QAction 更符合人们对 Action 的理解?

是的, Qt 的 QAction 是一个看得见、摸得着的 Action,理解起来舒适而自然,这也影响了很多开发人员对 Action 的认知,形成了先入为主的印象,同时也造成了对 GPUI Action 的困惑。虽然同为 Action,但 Qt 的 QAction 和 Zed 的 Action 有本质区别,这体现在:Qt 的 QAction 有文本、图标,自带 enabled、checked 等状态信息,它实际上已是一个准 UI 组件,跟 Zed 的 Action 相比,已经几乎不是同一种东西了。QAction 虽然可以方便地设给 MenuItem 或 Button,但它自带的数据和状态也是它的“禁锢”,它显然没法像 Zed 的 Action 那样可以通过配置文件灵活的配置,随意地绑定和解绑,更没有 Context 能力,所以,两者的差异是非常大的。应该说 Qt 的 QAction 是面向菜单和按钮设计的通用的 UI 组件,而 Zed 的 Action 则完全是为文本编辑器设计的键盘操作解决方案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询