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