

最近用 Rust 开发了一个 TUI 程序,对于 TUI 中的事件处理有了一些心得,写篇文章分享一下。
主要内容包括:
Note
本文为一次课程分享的内容整理,尝试了一下用 AI 将我的 slides 和讲稿转换成了这篇博文,所以遣词造句的 AI 味儿可能比较浓,但内容本身是我自己编写的。
要理解 TUI 开发,首先得明白它的基本运行模型。我们可以将其简化为以下几个核心概念:
这个模型可以用一个简单的流程图来表示:用户操作产生事件 -> 事件更新状态 -> 新状态渲染为 UI。
从上述模型中,我们可以清晰地将 TUI 的开发工作划分为两个主要部分:
对于UI渲染,市面上有不少优秀的库可以帮助我们,例如在 Rust 生态中,ratatui
就是一个广受欢迎的选择。通过为自定义的状态结构实现 ratatui::Widget
trait,我们就能利用 ratatui
提供的 Backend::draw
函数将 Widget
转换为终端缓冲,最终呈现在用户面前。
然而,事件处理往往是 TUI 开发中复杂度的主要来源。与UI渲染不同,这部分通常没有现成的库能够完美覆盖所有需求,更多时候需要我们自己设计和实现。本次分享将更偏向工程实践,旨在以尽量优雅的方式解决事件处理中遇到的问题。
在处理相对简单的应用逻辑时,我们可以直接响应事件并更新状态。但随着应用功能的增加,状态和事件的种类会急剧膨胀,直接处理将变得混乱不堪。这时,引入一种结构化的架构就显得尤为重要。
The Elm Architecture(TEA)是一种源自 Elm 语言的函数式 UI 架构,它将 UI 视为一个状态机,非常适合管理复杂的状态更新。一个标准的 TEA 应用主要由三部分组成:
在 Rust 中,我们可以将一个 TEA 组件实现为一个 struct
,并为其定义 render
(对应 View) 和 update
(对应 Update) 方法。
TEA 架构的核心优势在于它借鉴了状态机的思想。这种思想使得它在保持概念简单易用的同时,能够清晰、有序地处理复杂的状态转换逻辑。
让我们通过一个简单的 TODO 应用来看看 TEA 如何运作。假设我们的 TODO 应用有一个输入框用于添加新的 TODO事项,一个提交按钮,以及一个显示 TODO 列表的区域。我们暂时只考虑纯键盘操作。
首先,定义应用的状态(Model)和可能的操作(Action):
enum Focus { // 表示当前哪个组件拥有焦点
Input,
Submit,
}
struct TodoModel {
todos: Vec<String>, // TODO 事项列表
input_buffer: String, // 输入框中的当前文本
focus: Focus, // 当前焦点位置
edit_mode: bool, // 输入框是否处于编辑模式
}
最初我们可能想到这些操作:
enum Action {
Add(String), // 添加一个新的 TODO
MoveFocus, // 移动焦点 (例如在输入框和提交按钮间切换)
ToggleEditMode, // 切换输入框的编辑模式
}
随着思考深入,我们会发现需要更细致的 Action 来处理输入:
enum Action {
Add(String),
MoveFocus,
ToggleEditMode,
InputKeyEvent(KeyCode), // 代表一个键盘输入事件,传递给输入框处理
}
update
函数会根据传入的 Action
来修改 TodoModel
:
Add(String)
: 向 TodoModel::todos
列表中添加一个新的字符串。MoveFocus
: 切换 TodoModel::focus
的值(例如从 Input
到 Submit
,反之亦然)。ToggleEditMode
: 翻转 TodoModel::edit_mode
的布尔值。InputKeyEvent(KeyCode)
: 当输入框处于编辑模式时,此 Action 会根据具体的 KeyCode
(如字符、退格、回车) 来相应地修改 TodoModel::input_buffer
。用户的原始按键事件(Event)需要被映射到我们定义的 Action
:
edit_mode
为 false
(非编辑模式)时,左右方向键事件可能映射到 MoveFocus
Action。focus == Focus::Submit
)上时,按下回车键(Enter)映射到 Add(input_buffer.clone())
Action。focus == Focus::Input
)且不是编辑模式(!edit_mode
)时,按下回车键触发 ToggleEditMode
Action,进入编辑模式。edit_mode
为 true
(编辑模式)时:
InputKeyEvent(key)
Action。ToggleEditMode
Action,退出编辑模式。可以看到,即使是上述这个非常简单的 TODO 应用,其事件处理逻辑(特别是 Event 到 Action 的转换部分)也可能迅速膨胀到数十甚至上百行代码。如果继续使用单一的、巨大的 TEA 结构来构建更复杂的真实世界应用,我们会面临几个严峻的问题:
Action
枚举爆炸:随着功能增多,Action
的种类会越来越多,导致 update
函数变得异常冗长,各个逻辑分支高度耦合,难以维护。Model
状态臃肿:单一的 Model
需要维护应用中所有细枝末节的状态,使得状态管理变得异常困难和混乱。这些问题的解决方案,相信大家都很熟悉,那就是:组件化。
我们可以将 UI 拆分成一系列更小、可管理的组件。每个组件拥有自己独立的状态(Model)、事件处理逻辑(Update)和 UI 渲染方法(View)。组件可以被复用,从而有效地解决上述三个问题。
然而,在凡事几乎都要亲力亲为的 TUI 系统中引入组件,也为事件处理带来了新的挑战。
引入组件后,事件不再是简单地由顶层应用统一处理,而是需要在组件树中进行分发和响应。这主要涉及两个方面:事件的向下传播和事件的向上冒泡。
回想一下,在常见的图形 UI 应用中(例如 Web 开发),事件通常是如何被处理的?以一个 HTML 按钮为例:
button.addEventListener('click', () => { /* 执行某些操作 */ });
整个过程大致是:
(x, y)
位置点击了一下”的原始事件。(x, y)
位置的是一个 <button>
元素。<button>
元素上注册的 click
事件监听器。然而,在我们的 TUI 应用中,并没有一个类似“浏览器”的角色来帮我们判断哪个组件被“点击”了(因为 TUI 通常是基于键盘交互的,没有鼠标点击位置的概念,只有按键事件)。我们只知道用户按下了某个按键,那么,这个按键事件究竟应该由组件树中的哪一个组件来处理呢?这成为了我们需要解决的首要问题。
解决方案其实并不复杂,我们在之前的 TODO 例子中已经不自觉地使用过它的雏形:焦点(Focus)。
每个父组件,如果它包含子组件,可以维护一个类似 Option<Focus>
的状态。这个状态指明了当前哪个子组件拥有焦点。如果该状态为 None
(或某个特定值代表自身),则表示焦点在父组件自身。
当一个事件(如按键事件)到达父组件时:
focus
状态。focus
指向某个子组件,则将该事件向下传递给那个子组件。focus
表示焦点在自身,则由父组件自己处理该事件。这个过程可以递归地进行下去,直到事件被某个最深层拥有焦点的组件处理,或者在某个层级被消费掉。这样,我们就建立起了一套事件向下传播的机制,确保了事件能够被正确的组件接收。
在某些 UI 系统中,事件在被处理后,默认会向上“冒泡”到其父组件,父组件也可以选择处理该事件。例如,在 Web 的 DOM 结构中:
<div>
<button>Click me</button>
</div>
当用户点击 <button>
元素时,浏览器会首先调用 <button>
元素的 click
事件监听器。在默认情况下(如果事件没有被显式阻止冒泡),<div>
元素的 click
事件监听器(如果存在的话)也会被触发。
这种冒泡机制在 TUI 中同样非常有用。考虑以下场景:
如果当前焦点在列表组件上,用户正在用上下键浏览。此时若用户按下 'q' 键,我们期望的是应用能够退出,而不是列表组件尝试去处理 'q' 键(除非列表组件本身对 'q' 键有特定含义并希望优先处理)。这就需要事件能够从列表组件向上冒泡到根组件。
然而,并非所有事件都应该无条件地向上冒泡。组件应该有能力动态地决定一个事件在被自己处理后,是否还应该继续向上冒泡。例如:
inputting
属性(表示正在输入文本)为 true
时,如果用户按下 'q' 键,我们希望的是字符 'q' 被输入到文本框中,而不是导致应用退出。在这种情况下,当输入组件处理了 'q' 键并将其作为输入字符后,它应该阻止该事件继续向上冒泡。
有两种初步看来可行的实现方式,但它们各有弊端:
子组件持有父组件引用:子组件包含一个指向其父组件的引用。当需要冒泡时,子组件直接调用父组件的事件处理方法。
&mut self
)。如果父组件持有子组件,子组件又持有父组件的可变引用,很容易形成循环引用或违反借用规则,导致生命周期管理变得异常复杂。父组件读取子组件状态:父组件在决定是否处理一个可能由子组件冒泡上来的事件前,先去读取子组件的内部状态(例如,上例中的 inputting
属性),以此判断子组件是否已经“消费”了该事件或希望阻止冒泡。
一种在 Rust 中更优的思路:
我们可以换一种思路,让组件的事件处理函数的返回值来决定事件是否继续冒泡。例如,事件处理函数可以返回一个枚举 EventStatus
,它包含 Bubble
(继续冒泡)和 Stop
(停止冒泡)两个变体。
或者,我们可以直接从 Web API 中汲取灵感,为事件对象本身添加一个类似 stop_propagation()
的方法。子组件在处理事件时,如果调用了这个方法,那么父组件在接收到(或准备接收)这个事件时,可以检查其状态,如果已被标记为“停止传播”,则不再继续处理。
下面是一个简化的 Rust 代码示例,展示了如何通过返回值控制冒泡:
// 定义事件状态
enum EventStatus {
Bubble, // 事件继续冒泡
Stop, // 事件停止传播
}
// 子组件的事件处理
impl ChildComponent {
fn handle_event(&mut self, event: KeyEvent) -> EventStatus {
if self.inputting { // 假设 inputting 是子组件的一个状态
// ... 处理输入逻辑 ...
// 如果正在输入,我们消耗了 'q' 键,不希望它冒泡去触发退出
if event.code == KeyCode::Char('q') {
self.buffer.push('q');
return EventStatus::Stop; // 消耗事件,停止冒泡
}
// ... 其他输入处理 ...
EventStatus::Stop // 通常输入组件会消耗大部分按键事件
} else {
// 如果不在输入模式,可能某些键对子组件无意义,允许冒泡
if event.code == KeyCode::Char('j') {
// 子组件处理了 'j' 键,并决定停止冒泡
self.scroll_down();
return EventStatus::Stop;
}
EventStatus::Bubble // 其他键允许冒泡
}
}
}
// 父组件的事件处理
impl ParentComponent {
fn handle_event(&mut self, event: KeyEvent) -> EventStatus {
let mut child_status = EventStatus::Bubble; // 默认允许冒泡
// 根据焦点将事件传递给子组件
if self.focus == Focus::ChildInput { // 假设 focus 指向子组件
child_status = self.child_input.handle_event(event);
}
// ... 可能还有其他子组件 ...
// 如果子组件要求停止冒泡,则父组件也停止
if matches!(child_status, EventStatus::Stop) {
return EventStatus::Stop;
}
// 若事件从子组件冒泡上来 (child_status == EventStatus::Bubble)
// 父组件现在可以处理这个事件了
if event.code == KeyCode::Char('q') {
// 父组件处理 'q' 键,例如退出应用
self.should_quit = true;
return EventStatus::Stop; // 父组件消耗事件
}
EventStatus::Bubble // 如果父组件也不处理,事件可以继续向上冒泡(如果还有更上层)
}
}
通过精心设计事件的向下传播(基于焦点)和向上冒泡(基于事件处理的返回值或状态标记)机制,我们既实现了“组件化”来复用代码和分解复杂度,又给每个组件留下了非常大的自由度。每个组件只需要实现一个至少能表明事件传播状态(如返回 EventStatus
)的 handle_event
方法即可。
这是一个相对低级别的抽象,它允许每个组件根据自身需求自由地实现其内部逻辑。如果组件逻辑简单,一个 match
表达式可能就足够了;如果组件内部状态转换复杂,它甚至可以在其内部再使用 TEA 架构来管理。
更进一步,事件处理的返回值也不必局限于 EventStatus
。例如,一个输入组件的 handle_event
方法,除了返回 EventStatus
外,还可以返回一个 Option<String>
。当用户在输入组件中完成输入并提交时(比如按下回车),它就可以返回 (EventStatus::Stop, Some(entered_text))
,父组件据此可以获取到输入结果。
当我们掌握了组件化的事件处理之后,构建一个多页面的 TUI 应用也就水到渠成了。
问:如何从组件化的程序出发,实现一个多页面的程序?
答:可以将每一个“页面”也视为一个大型的、独立的“页面组件”。然后,让应用的根组件(或一个专门的页面管理器组件)负责根据当前应用状态来渲染和切换这些页面组件。这样,根组件就扮演了页面调度器的角色。
问:如何实现“返回上一页”功能,并且保留上一页面的状态?
答:一个常见的做法是在根组件(或页面管理器)内部维护一个栈(Stack)。这个栈里存储的不是完整的页面组件实例(这可能涉及复杂的生命周期和状态复制),而是能够重建页面组件的“关键状态”或页面标识。
问:页面组件如何通知根组件(或页面管理器)进行页面操作,如打开新页面、切换到特定页面或关闭当前页面?
答:这涉及到组件间的通信。一种有效的方式是使用异步消息传递通道,例如 Rust 中 tokio::sync::mpsc
模块提供的 unbounded_channel
(如果你的 TUI 应用是异步的)。
最后,我们来聊聊选择 Rust 来开发 TUI 应用的一些考量。除了 Rust,像 Golang、Python、JavaScript 等语言也都有其活跃的 TUI 开发社群和不错的库。
BubbleTea
库,它不仅提供了 TEA 架构的实现,还有大量预置的组件和活跃的社区。除了这些原生编译型语言,Python 和 JavaScript 的生态较 Rust 相比也更加完善,而他们的性能在绝大多数 TUI 场景下也是足够的。
可以看出,Rust 其实在 TUI 领域没有什么特别的优势,但是,近年来 Rust在 TUI 领域的关注度也在逐渐提升。例如,微软使用 Rust 开发了其终端编辑器 Edit
,OpenAI 也使用 Rust 和 ratatui
构建了其 Codex CLI 中的 TUI 部分。随着这些大公司的投入和成功案例的出现,我们可以期待 Rust 的 TUI 生态会变得越来越完善和强大。
回顾本次的分享,我们探讨了 TUI 事件处理的几个关键方面:
focus
状态来实现,指明当前哪个子组件接收事件。EventStatus::Bubble
或 EventStatus::Stop
)或事件对象自身的状态来控制,允许子组件决定事件是否继续向上传递。本文使用“署名-非商业性使用-相同方式共享 4.0 国际(CC BY-NC-SA 4.0)”进行许可。
商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接。 如果您再混合、转换或者基于本作品进行创作,您必须基于相同的协议分发您贡献的作品。