『rust-tui-p1: 完整架构』

写自:2024-06-22

作者:柳下川

系列:rust-tui

摘要
本节我们将学习一个 tui 应用的基本架构, 内容包括输入处理, 视图更新, 了解终端中诸如原始模式, 文件描述符, 重定向, 管道等概念

目录:

├───状态管理
├───处理程序
├───视图渲染
├───事件捕获
├───终端设置
├───原始模式
├───文件描述符
├───重定向
├───管道
└─尾声

🔗 模板项目

本节的内容大致是带你捋下一个 tui 应用的基本架构, 参考项目是官方的 模板文件
在这之前, 你可以选择下载 cargo-generate 来快速开发 (当然光看下面的代码与概念也不是不行):

cargo install cargo-generate

上面的项目是一个模板项目, 专门为了让别人快速开始敲代码, 而不是将时间浪费在编写大量重复代码身上
如果你选择下载了 cargo-generate, 你可以通过如下命令快速搭建一个叫做 tui-demo 的项目
(为了某些同学的网速考虑, 已经换成 gitee 镜像仓库了):

cargo generate --git https://gitee.com/mirror_jedsek/rust-tui-template --name tui-demo

你可以运行它, 然后看看效果如何:

cd tui-demo cargo run

你会获得一个经典的双按钮应用: 显示一个数字, 随着按键而 +1 或 −1, 差不多长这样:

┌────────────────────────────────────Template─────────────────────────────────────┐ │ This is a tui template │ │ Press `Esc`, `Ctrl-C` or `q to stop running. │ │ Press left and right to increment and decrement th counter respectively. │ │ Counter: 0 │ │ │ └─────────────────────────────────────────────────────────────────────────────────┘

搭建过程讲完了, 下面开始正文, 关于架构的讲解


🔗 架构说明

src/ ├── app.rs -> 保存状态与应用逻辑 ├── event.rs -> 将待处理的终端事件包装成我们自己定义的事件模型, 并且分发事件 (key/mouse/rsize 等) ├── handler.rs -> 接受分发的事件, 并让具体的处理函数与其一一匹配 (本系列我们只处理按键事件, 即 key-event) ├── lib.rs -> 定义模块 (别把 `pub mod xxx` 写在 `main.rs` 里面而已) ├── main.rs -> 应用程序的入口 ├── tui.rs -> 初始化/退出tui时的一些操作 └── ui.rs -> 渲染组件ui

项目中同时存在 lib.rs 与 main.rs, 因此存在两个 crate, 前者是 lib_crate, 后者是 bin_crate

// src/lib.rs /// Application. pub mod app; /// Terminal events handler. pub mod event; /// Widget renderer. pub mod ui; /// Terminal user interface. pub mod tui; /// Event handler. pub mod handler;

lib.rs 定义了这些模块, 因此我们就不必在 main.rs 的头部再定义了

注意:
当 lib.rs 与 main.rs 同时存在时, 视 lib.rs 为项目根, 调用里面的东西用 crate::xxx
而 main.rs 则视作作为依赖的bin_crate, 比如你的项目叫 tui_app, 调用里面的东西时就用 tui_app::xxx 即可

看着这些架构, 你可能会有点懵, 没事, 爷慢慢跟你说, 保证你能明白

> 状态管理

我们从简单开始, 先看 app.rs:

// src/app.rs use std::error; /// Application result type. pub type AppResult<T> = std::result::Result<T, Box<dyn error::Error>>; /// Application. #[derive(Debug)] pub struct App { /// Is the application running? pub running: bool, /// counter pub counter: u8, } impl Default for App { fn default() -> Self { Self { running: true, counter: 0, } } } impl App { /// Constructs a new instance of [`App`]. pub fn new() -> Self { Self::default() } /// Handles the tick event of the terminal. pub fn tick(&self) {} /// Set running to false to quit the application. pub fn quit(&mut self) { self.running = false; } pub fn increment_counter(&mut self) { if let Some(res) = self.counter.checked_add(1) { self.counter = res; } } pub fn decrement_counter(&mut self) { if let Some(res) = self.counter.checked_sub(1) { self.counter = res; } } }

逻辑很简单, 结构体 App 里装着随应用运行而可能被改变的状态, 除此以外, 我们还定义了一些方法来修改这些状态
比如 quit() 会让 running 变成 false, 以此来退出程序
比如 inc()/dec() 会让要显示的数字的值 +1/-1, 并保证处于 0..=255 的范围内不产生溢出

你可能会问, tick() 这玩意是干啥的? 空方法有啥用?

在这之前, 你需要了解下 Tick 的概念, 其意思是时钟秒针的滴答声, 用来表示时间的最小化单位
现实世界中目前可观测到的时间的最小单位是普朗克时间, 但一个运行在计算机里面的应用肯定不能这么算啊

举个例子, 在著名的MMO类型游戏, Screeps 中, 游戏的基本时间单位就叫 tick
所有玩家的代码会在同一时间并行执行, 随后以最后一份代码执行的结束, 代表着这一 tick 的结束
再举个例子, 在大部分枪战fps游戏中, 也有 tick 这个概念, 代表每秒钟可以刷新多少次

在该模板项目中, 会在一个设定好的时间如 200ms 后, 尝试着捕获一次终端事件, 若什么事件也没, 那就分发一个 Event::Tick 给我们的处理程序
在处理程序中, 当接受到 Tick 事件时, 我们返回一个 (), 以此来无视它, 状态的变化啊, 视图的更改啊什么的, 也就根本不会发生

这里的 tick() 方法, 也就是我们对 Event::Tick 事件的处理函数啦! 函数体是空的也就表示着无视它哦

> 处理程序

讲完了 app.rs状态的保存状态的更改 之后, 我们会来看下 handler.rs, 即对事件的处理部分:

// src/handler.rs use crate::app::{App, AppResult}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; /// Handles the key events and updates the state of [`App`]. pub fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult<()> { match key_event.code { // Exit application on `ESC` or `q` KeyCode::Esc | KeyCode::Char('q') => { app.quit(); } // Exit application on `Ctrl-C` KeyCode::Char('c') | KeyCode::Char('C') => { if key_event.modifiers == KeyModifiers::CONTROL { app.quit(); } } // Counter handlers KeyCode::Right => { app.increment_counter(); } KeyCode::Left => { app.decrement_counter(); } // Other handlers you could add here. _ => {} } Ok(()) }

crossterm 是一个用于操作终端的跨平台库, 提供了一些抽象与封装, 比如 KeyEvent:

pub struct KeyEvent { /// The key itself. pub code: KeyCode, /// Additional key modifiers. pub modifiers: KeyModifiers, // ...... // ...... // ...... }


顺带一提, 类似的终端操作库还有:


ratatui-rs 对其都有适配与支持, 比如当你如果想使用 termwiz + ratatui, 可以这样:

tui = { package = "ratatui", version = "0.24", default_features = false, features = ["crossterm", "macros"] } termwiz = { version = "0.20.0", features = ["use_image"] }

本系列都将使用 crossterm, 因为资料多文档全嘛

位于 handler.rs 中的代码很好理解, 不过你可能还会再问:
实际的处理程序, 好像就是 app.rs 里面的方法啊?
那能不能取消这个模块, 全装到 App 里面去呢?

刑, 当然可刑, 只是耦合度太高了, 不能想现在这样一目了然

你想啊, 如果你全装到 app.rs 里面去, 本来只需要管理状态, 对状态进行保存与更改
但现在就得与其他模块, 比如 event.rs 接轨打交道, 接受分发过来的事件并匹配相应的处理函数
耦合度太高了! 各种事情粘在一起, 耦合在一起, 复杂啊!

因此, handler.rs 是必不可少的, 其作用就像是网页中的路由那样, 调用着 app.rs 中改变状态的方法, 降低了耦合度

注意: 此处仅处理按键事件, 这也是大多数 tui 应用会处理的唯一事件
你没有看见 app::tick(), 是因为 Event::Tick 不是按键事件, 自然没有在处理按键的相应函数中看见
main.rs 中, 对全部终端事件的处理是这样的 (后面会讲) :

// src/main.rs // Handle events. match tui.events.next()? { Event::Tick => app.tick(), Event::Key(key_event) => handle_key_events(key_event, &mut app)?, Event::Mouse(_) => {} Event::Resize(_, _) => {} }

> 视图渲染

下面的代码渲染了一个组件 Paragraph, 将 app 里面的 状态, 即 app.counter 放上去显示
因为 ratatui-rs 是 即时模式 的渲染策略, 所以用起来会比较简单, 当按下按键后 counter 加减, 视图会因为重渲染自己更新

根据其文档介绍, 它会在每一个新帧渲染所有 UI
这虽然为丰富的交互式 UI 提供了极大的灵活性,但也可能会因高度动态的内容而带来开销
(根据这份README所说, 实际上鉴于Rust的速度, 开销一般来自于终端, 而不是库本身)

// src/ui.rs use tui::{ backend::Backend, layout::Alignment, style::{Color, Style}, widgets::{Block, BorderType, Borders, Paragraph}, Frame, }; use crate::app::App; /// Renders the user interface widgets. pub fn render<B: Backend>(app: &mut App, frame: &mut Frame<B>) { // This is where you add new widgets. // See the following resources: // - https://docs.rs/ratatui/latest/ratatui/widgets/index.html // - https://github.com/tui-rs-revival/ratatui/tree/master/examples frame.render_widget( Paragraph::new(format!( "This is a tui template.\n\ Press `Esc`, `Ctrl-C` or `q` to stop running.\n\ Press left and right to increment and decrement the counter respectively.\n\ Counter: {}", app.counter )) .block( Block::default() .title("Template") .title_alignment(Alignment::Center) .borders(Borders::ALL) .border_type(BorderType::Rounded), ) .style(Style::default().fg(Color::Cyan).bg(Color::Black)) .alignment(Alignment::Center), frame.size(), ) }

> 事件捕获

接下来, 我们将学习 event.rs 里的代码, 它们之后基本不会更改

在这里, 我们定义了自己的 Event 枚举, 并且其变体包装了来自 crossterm 库的一些事件
当我们使用 event::poll 来捕获事件时, 捕获的事件, 被封装成了我们自己定义的 Event 枚举, 方便传递给程序的其他部分进行处理
这里使用了 mpsc-channel 来处理事件的发送与接受, 即多生产者与单消费者模型, 不熟悉的可以去看一下 rust 的官方教程

你会看见这样一份代码:

let timeout = tick_rate .checked_sub(last_tick.elapsed()) .unwrap_or(tick_rate);

上面的代码具有什么样的含义呢?

答:


如果流逝的时间要比一个 tick 大, 这里选择了将一个 tick_rate 作为 timeout 来使用

> 终端设置

我们创建了一个叫做 TUI 的结构体, 它代表了你的 Terminal-User-Interface
其有两个成员, 一个代表你的终端, 一个代表了先前的 EventHandler

你当然能直接使用 EventHandler, 而不是将其作为 TUI 的成员来使用, 只是这里仅仅作了一个抽象与包装而已
毕竟, 在该模板项目中, EventHandler 仅仅出现了三次, 一次是其自己的定义, 一次是它作为 TUI 成员的定义
还有一次是其在 main 中被创建, 然后传递给 TUI 用作初始化 (笑

initexit 分别对应了当应用开始时的初始设置, 与结束时的清理收尾工作

这里大多顾名思义, 比如 AlternateScreen 表示 tui 应用的渲染应该发生在 "另一张新的画布上", 避免破坏原来的界面
当你结束程序时, 退出了 "新画布", 你的渲染都是发生在 "新画布" 上, 自然不会影响原来

你会发现在 initexit/reset 中, crossterm::execute! 是针对 io::stderr() 的, 为何不用 io::stdout() 呢?
这是因为我们想在 stderr 中进行 ui 的渲染, 而程序的输出则放到 stdout, 这样有利于通过 管道(pipeline), 让输出传递给其他程序


🔗 终端概念

有些人可能不太了解什么是 raw_mode, 什么是 pipeline, 分不清 stdout 与 stderr, 所以我会多说明下这些概念

注意:
这些概念已经尽量简化过了, 因为写个 tui 程序不必太深入, 我也不知道我讲的够不够简洁明了, 见谅

> 原始模式

你可以观看 来自 gnu 官网的说明, 里面有对此的解释:

在 POSIX 系统中, 有着两种输入模式: canonical 或者 noncanonical
终端 IO 存在着这两种主要的模式:

在该模式下, 会以行为单位, 读取输入(read函数), 在终端输入的字符被保存在缓冲区中
当输入行结束符(换行/EOF)时, 缓冲区的内容才会被发送到对应的处理程序
特殊字符不会被无视, 换行符也会像普通字符一样被缓冲区接收, 但 EOF 不会

类似 ERASE 与 KILL 这些特殊的编辑字符会被禁用, 每次输入都会读取, 而不再是基于 line 为单位
比如 vim, 或者我现在正在使用的 helix-editor, 就处于该模式下, 方便程序捕获并独占你的每份输入, 方便处理

ratatui 中位于 into_raw_mode 的文档, 对于原始模式下的输入的描述如下:

原始模式(raw-mode), 意味着你所输入的标准输入不会被打印/不会显示在屏幕上(它必须由程序手动编写)
此外, 输入不是 canonical 或 buffered 的, 也就是说, 您可以一次读取一个字节


简单来说, 原始模式让我们可以逐一捕获用户的输入, 便于对其进行处理
并且禁用且无视了特殊的字符, 进一步增强了我们程序的权能与对终端的掌控权限
(想想 vim 的操作, 再想想你在 bash 输入命令, 对比一下两者的区别)

> 文件描述符

在 Linux 系统中, 万物皆文件, 不管是进程也好, 还是你的设备也好, 都是文件, 且有一个对应的文件描述符(file descriptor)
这个文件描述符, 可以看作是系统为了追踪打开的文件而分配的唯一标号, 通过它对文件进行读写

Linux 在启动时, 会创建一个 init 进程, 此时自动创建 3 个特殊的文件描述符, 对应 3 个设备IO文件:

而之后产生的进程, 则都是 init 进程的子进程(一颗多叉树), 子进程会继承父进程的文件描述符
因此, 你打开的 shell(bash, zsh, fish, nushell等), 因为是子进程, 自然会有这三个文件描述符

简单理解:


之后的子进程中所打开的文件, 文件描述符则从 3 开始往上递增

> 重定向

重定向(redirect), 光听名字就晓得什么意思了, 与网页的重定向一个道理
我们的 tui 程序, 就可以使用 管道(pipeline, 重定向的一种) 来跟更多的其他程序进行交互, 之后会讲

举个例子, 当你执行程序时, 从 stdin(键盘) 获取输入, 成功则将结果输出到 stdout(屏幕), 失败则将报错输出到 stderr(屏幕) 上
这是你没有指定任何重定向时的默认行为, 你会发现 stdout/stderr 都会输出到屏幕上, 极其容易造成混乱

让我们用 rust 来进行一点演示, 首先创建项目并跳转

cargo new --bin demo cd demo

// src/main.rs fn main() { println!("This is a str, which will be outputted to stdout"); eprintln!("This is a str, which will be outputted to stderr"); }

接下来运行它:

# cargo run --quiet This is a str, which will be outputted to stdout This is a str, which will be outputted to stderr

println! 对应的应该是我们正常的输出, 而 eprintln! 应该对应一些报错信息, 可是它们同时出现了
这太混乱了, 当向 stdout 与 stderr 大量输出时更是如此, 我们要的是将 正确的输出报错的信息 分开!

请对比以下几条条命令(在bash中运行):

# 全部输出到屏幕 This is a str, which will be outputted to stdout This is a str, which will be outputted to stderr

# 重定向, 输出到 output.log 中, 默认只重定向 stdout This is a str, which will be outputted to stderr

# 指定文件描述符为 1(stdout), 默认行为 This is a str, which will be outputted to stderr

# 指定文件描述符为 2(stderr), stderr 的输出存入 error.log This is a str, which will be outputted to stdout

简单地了解下就差不多了, 其实还有很多内容, 不过懒得展开了, 知道两者区别就够了

本节最后使用 nushell(比bash/zsh等更加现代的shell) 展示一下:
(nushell 可以直接帮我们结构化信息, 放上来可以方便小伙伴们理解)

再来一个例子, 当使用 cat 命令时传入一个不存在的文件:

而如果用 bash 自然就无法简单地得到如此结构化的数据了, 仅仅只能看见一条简单的消息:

cat: non-exist.txt: No such file or directory

> 管道

此处以 bash 中的管道(pipeline)为例
管道命令的操作符时 "|", 形式为 command_1 | command_2, 表示将前者的输出作为后者的输入, 即 stdout -> stdin
使用管道时, 两个命令会依次被调用

值得注意的有两点:


来点例子, 假设存在这么一个文件 README.md:

# 当 cat 向 stdout 输出时被管道传走, 屏幕上不显示被传走的内容 57

# 当 cat 向 stderr 输出时无法被传递, 屏幕上显示错误消息, 且 wc 的 stdin 为空 cat: REDM.m: No such file or directory 0

# 即使 cat 输出至 stdout, 但 ls 不支持 stdin, 屏幕上不显示前者结果, 数据被丢弃了 Documents Downloads Music Pictures Videos Trash

管道与重定向的区别:


管道在 tui 程序中的常见用途:
假设我们的 tui 程序, 想在执行的时候将结果交给外部的命令, 我们就可以通过管道来支持这一行为
比如, 我们做了一个超级小的终端文件浏览器demo, 类似 ranger/nnn/xplr/yazi, 在选择文件按下回车后, 能获得其 path
假设这个程序叫作 file_chooser, 我们想将获取的路径传递给其他程序, 类似这样:

file_chooser | vim

其获取的文件路径, 通过管道传递给了 vim, 然后可以在 vim 中编辑这个文件

但请注意, 我们的 file_chooser 是 tui 程序, 也是一堆字符所组成的, 如果你让这些字符(ui), 渲染输出到了 stdout 中…
那通过管道传递的, 可就不止是你真正想传递给外部的文件路径了! 还有你的一大堆 ui 字符!

真是可怕啊…不过解决办法非常简单, 你只要在 stderr 中渲染你的 ui 界面就阔以了
所以我才在 p2 中提过一嘴, 为什么那个模板是使用了 stderr, 而不是使用 stdout


🔗 尾声

最后回到我们的 main 函数, 经过前面那么多的铺垫, 我相信你一定可以读懂下面的代码了吧?!

// src/main.rs use std::io; use tui::backend::CrosstermBackend; use tui::Terminal; use tui_demo::app::{App, AppResult}; use tui_demo::event::{Event, EventHandler}; use tui_demo::handler::handle_key_events; use tui_demo::tui::Tui; fn main() -> AppResult<()> { // Create an application. let mut app = App::new(); // Initialize the terminal user interface. let backend = CrosstermBackend::new(io::stderr()); let terminal = Terminal::new(backend)?; let events = EventHandler::new(250); let mut tui = Tui::new(terminal, events); tui.init()?; // Start the main loop. while app.running { // Render the user interface. tui.draw(&mut app)?; // Handle events. match tui.events.next()? { Event::Tick => app.tick(), Event::Key(key_event) => handle_key_events(key_event, &mut app)?, Event::Mouse(_) => {} Event::Resize(_, _) => {} } } // Exit the user interface. tui.exit()?; Ok(()) }

鉴于 ratatui 是个非常简单的库, 讲完它的架构之后, 其实就已经可以开始写项目了
所以之后就稍微讲下用 ratatui 实现的小项目吧, 像是自定义组件, 处理用户输入等内容, 则会搭配着这些项目来说明

谢谢观看!