logo Yunfi

Rust TUI 事件处理:从 TEA 到组件化

最近用 Rust 开发了一个 TUI 程序,对于 TUI 中的事件处理有了一些心得,写篇文章分享一下。

主要内容包括:

  1. TUI 程序的基本运行模型
  2. TEA 架构:一种优雅的事件处理模式
  3. 组件化带来的事件处理新挑战
  4. 如何构建多页面 TUI 程序

Note

本文为一次课程分享的内容整理,尝试了一下用 AI 将我的 slides 和讲稿转换成了这篇博文,所以遣词造句的 AI 味儿可能比较浓,但内容本身是我自己编写的。

TUI 程序是如何运作的?

要理解 TUI 开发,首先得明白它的基本运行模型。我们可以将其简化为以下几个核心概念:

  1. 状态(State):应用程序自身拥有的数据和当前状况。
  2. UI 显示(UI Display):可以看作是当前状态的一个函数。它将状态渲染到终端屏幕上,供用户查看。
  3. 事件(Event):用户的操作(如键盘输入)、系统信号或其他外部输入。事件会触发状态的更新。
  4. 主循环(Main Loop):程序的核心部分,会不断地尝试从终端读取新的事件,并驱动后续的逻辑。

这个模型可以用一个简单的流程图来表示:用户操作产生事件 -> 事件更新状态 -> 新状态渲染为 UI。

model-ui-event

TUI 开发的核心任务

从上述模型中,我们可以清晰地将 TUI 的开发工作划分为两个主要部分:

  1. 根据“状态”渲染“UI 显示”(渲染 UI):这部分关注如何将程序内部的状态数据,通过一定的布局和样式,绘制到终端界面上。
  2. 处理“事件”并更新“状态”(事件处理):这部分关注如何接收用户的输入或其他事件,并根据这些事件来修改程序的状态,从而驱动界面的变化。

对于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,并将其渲染到屏幕上。

ELM

在 Rust 中,我们可以将一个 TEA 组件实现为一个 struct,并为其定义 render (对应 View) 和 update (对应 Update) 方法。

TEA 架构的核心优势在于它借鉴了状态机的思想。这种思想使得它在保持概念简单易用的同时,能够清晰、有序地处理复杂的状态转换逻辑。

示例:一个简单的 TODO 应用

让我们通过一个简单的 TODO 应用来看看 TEA 如何运作。假设我们的 TODO 应用有一个输入框用于添加新的 TODO事项,一个提交按钮,以及一个显示 TODO 列表的区域。我们暂时只考虑纯键盘操作。

todo-app

首先,定义应用的状态(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 的值(例如从 InputSubmit,反之亦然)。
  • ToggleEditMode: 翻转 TodoModel::edit_mode 的布尔值。
  • InputKeyEvent(KeyCode): 当输入框处于编辑模式时,此 Action 会根据具体的 KeyCode (如字符、退格、回车) 来相应地修改 TodoModel::input_buffer

Event 到 Action 的转换:

用户的原始按键事件(Event)需要被映射到我们定义的 Action

  • edit_modefalse(非编辑模式)时,左右方向键事件可能映射到 MoveFocus Action。
  • 当焦点在提交按钮(focus == Focus::Submit)上时,按下回车键(Enter)映射到 Add(input_buffer.clone()) Action。
  • 当焦点在输入框(focus == Focus::Input)且不是编辑模式(!edit_mode)时,按下回车键触发 ToggleEditMode Action,进入编辑模式。
  • edit_modetrue(编辑模式)时:
    • 大部分按键(如字母、数字)触发 InputKeyEvent(key) Action。
    • 按下 ESC 键触发 ToggleEditMode Action,退出编辑模式。
  • ...等等其他可能的映射。

当应用逐渐变大:组件化

可以看到,即使是上述这个非常简单的 TODO 应用,其事件处理逻辑(特别是 Event 到 Action 的转换部分)也可能迅速膨胀到数十甚至上百行代码。如果继续使用单一的、巨大的 TEA 结构来构建更复杂的真实世界应用,我们会面临几个严峻的问题:

  1. Action 枚举爆炸:随着功能增多,Action 的种类会越来越多,导致 update 函数变得异常冗长,各个逻辑分支高度耦合,难以维护。
  2. Model 状态臃肿:单一的 Model 需要维护应用中所有细枝末节的状态,使得状态管理变得异常困难和混乱。
  3. 代码复用困难:如果应用中有一些通用的控件(如自定义按钮、列表视图等)被多次使用,我们将不得不重复编写相似的状态定义和事件处理逻辑。

这些问题的解决方案,相信大家都很熟悉,那就是:组件化

我们可以将 UI 拆分成一系列更小、可管理的组件。每个组件拥有自己独立的状态(Model)、事件处理逻辑(Update)和 UI 渲染方法(View)。组件可以被复用,从而有效地解决上述三个问题。

然而,在凡事几乎都要亲力亲为的 TUI 系统中引入组件,也为事件处理带来了新的挑战。

组件化带来的事件处理新挑战

引入组件后,事件不再是简单地由顶层应用统一处理,而是需要在组件树中进行分发和响应。这主要涉及两个方面:事件的向下传播和事件的向上冒泡。

1. 事件的向下传播:哪个组件来响应?

回想一下,在常见的图形 UI 应用中(例如 Web 开发),事件通常是如何被处理的?以一个 HTML 按钮为例:

button.addEventListener('click', () => { /* 执行某些操作 */ });

整个过程大致是:

  1. 浏览器接收到一个“用户在屏幕 (x, y) 位置点击了一下”的原始事件。
  2. 浏览器通过命中检测,发现位于 (x, y) 位置的是一个 <button> 元素。
  3. 浏览器随后调用这个 <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' 键并将其作为输入字符后,它应该阻止该事件继续向上冒泡。

有两种初步看来可行的实现方式,但它们各有弊端:

  1. 子组件持有父组件引用:子组件包含一个指向其父组件的引用。当需要冒泡时,子组件直接调用父组件的事件处理方法。

    • 弊端:在 Rust 这样的语言中,这种方式非常棘手。事件处理通常需要对组件状态进行可变借用(&mut self)。如果父组件持有子组件,子组件又持有父组件的可变引用,很容易形成循环引用或违反借用规则,导致生命周期管理变得异常复杂。
  2. 父组件读取子组件状态:父组件在决定是否处理一个可能由子组件冒泡上来的事件前,先去读取子组件的内部状态(例如,上例中的 inputting 属性),以此判断子组件是否已经“消费”了该事件或希望阻止冒泡。

    • 弊端:这种方式会导致父组件和子组件之间产生深度耦合。子组件内部状态的任何变更,如果影响到冒泡逻辑,都可能需要同步修改父组件的代码,降低了组件的独立性和可维护性。

一种在 Rust 中更优的思路:

我们可以换一种思路,让组件的事件处理函数的返回值来决定事件是否继续冒泡。例如,事件处理函数可以返回一个枚举 EventStatus,它包含 Bubble(继续冒泡)和 Stop(停止冒泡)两个变体。

event-bubbling

或者,我们可以直接从 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::BubbleEventStatus::Stop)或事件对象自身的状态来控制,允许子组件决定事件是否继续向上传递。
  • 多页面 TUI 程序可以通过将每个页面视为一个独立的组件来实现,并使用栈来管理页面切换和状态恢复。

本文使用“署名-非商业性使用-相同方式共享 4.0 国际(CC BY-NC-SA 4.0)”进行许可。

商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接。 如果您再混合、转换或者基于本作品进行创作,您必须基于相同的协议分发您贡献的作品。

logo Yunfi
2023-2024 Yunfi. | RSS | Site Map Powered by Astro. See all Credits.