rust-tui-p2~> 基础架构
<~~ 发表日期:2023-06-22 | 本文词数:3783 | 预计阅读时间:19分钟 ~~>

本节我们将学习一个 tui 应用的基本架构

同系列传送门: rust-tui

模板项目

本节的内容大致是带你捋下一个 tui 应用的基本架构, 参考项目是官方的 ratatui-org/rust-tui-template 模板文件
在这之前, 你可以选择下载 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-event, mouse-event, resize-event 等)
├── handler.rs -> 接受分发的事件, 并让具体的处理函数与其一一匹配 (本系列我们只处理按键事件, 即 key-event)
├── lib.rs     -> 定义模块 (单纯的别把 `pub mod xxx` 写在 `main.rs` 里面而已) 
├── main.rs    -> 应用程序的入口
├── tui.rs     -> 初始化/退出tui时的一些操作
└── ui.rs      -> 渲染组件ui

项目中同时存在 lib.rsmain.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.rsmain.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,
    
    // ......
    // ......
    // ......
}
  • code: 表示你按下的比如字母啊数字啊这种
  • modifiers: 表示修饰键, 如 Shift/Ctrl/Alt/Super (windows里的图标键, 或macos里的cmd键)

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

  • termion: 不跨平台, 仅限linux
  • termwiz: wezterm 的配套终端操作库, 有很多高级特性 + 跨终端, 但文档少而且api有亿点难用

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

Cargo.toml
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(_, _) => {}
    }
// ......

视图渲染

一样, 我们先把简单的模块看了:

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(),
    )
}

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

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

事件捕获

接下来, 我们将学习 event.rs 里的代码, 它们之后基本不会更改, 这里也就看个乐, 清楚下架构而已:

src/event.rs
use crate::app::AppResult;
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};

/// Terminal events.
#[derive(Clone, Copy, Debug)]
pub enum Event {
    /// Terminal tick.
    Tick,
    /// Key press.
    Key(KeyEvent),
    /// Mouse click/scroll.
    Mouse(MouseEvent),
    /// Terminal resize.
    Resize(u16, u16),
}

/// Terminal event handler.
#[allow(dead_code)]
#[derive(Debug)]
pub struct EventHandler {
    /// Event sender channel.
    sender: mpsc::Sender<Event>,
    /// Event receiver channel.
    receiver: mpsc::Receiver<Event>,
    /// Event handler thread.
    handler: thread::JoinHandle<()>,
}

impl EventHandler {
    /// Constructs a new instance of [`EventHandler`].
    pub fn new(tick_rate: u64) -> Self {
        let tick_rate = Duration::from_millis(tick_rate);
        let (sender, receiver) = mpsc::channel();
        let handler = {
            let sender = sender.clone();
            thread::spawn(move || {
                let mut last_tick = Instant::now();
                loop {
                    let timeout = tick_rate
                        .checked_sub(last_tick.elapsed())
                        .unwrap_or(tick_rate);

                    if event::poll(timeout).expect("no events available") {
                        match event::read().expect("unable to read event") {
                            CrosstermEvent::Key(e) => sender.send(Event::Key(e)),
                            CrosstermEvent::Mouse(e) => sender.send(Event::Mouse(e)),
                            CrosstermEvent::Resize(w, h) => sender.send(Event::Resize(w, h)),
                            _ => unimplemented!(),
                        }
                        .expect("failed to send terminal event")
                    }

                    if last_tick.elapsed() >= tick_rate {
                        sender.send(Event::Tick).expect("failed to send tick event");
                        last_tick = Instant::now();
                    }
                }
            })
        };
        Self {
            sender,
            receiver,
            handler,
        }
    }

    /// Receive the next event from the handler thread.
    ///
    /// This function will always block the current thread if
    /// there is no data available and it's possible for more data to be sent.
    pub fn next(&self) -> AppResult<Event> {
        Ok(self.receiver.recv()?)
    }
}

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

你会看见这样一份代码:

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

tick_rate 代表了一个 tick 的具体时间, timeout 顾名思义代表了后面 event::poll 应该等待多久, 超时了自然就结束
checked_sub 表示了一个 tick_rate, 经历了 last_tick.elapsed() 之后还剩下的时间, 以此作为 timeout
如果流逝的时间要比一个 tick 大, 这里选择了将一个 tick_rate 作为 timeout 来使用

终端设置

呕吼, 你即将观看的代码, 其作用是设置与调整终端的环境, 以便我们的 tui 应用更好的运行, 接下来会逐步讲解相关概念
代码如下:

src/tui.rs
use crate::app::{App, AppResult};
use crate::event::EventHandler;
use crate::ui;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use std::io;
use std::panic;
use ratatui::backend::Backend;
use ratatui::Terminal;

/// Representation of a terminal user interface.
///
/// It is responsible for setting up the terminal,
/// initializing the interface and handling the draw events.
#[derive(Debug)]
pub struct Tui<B: Backend> {
    /// Interface to the Terminal.
    terminal: Terminal<B>,
    /// Terminal event handler.
    pub events: EventHandler,
}

impl<B: Backend> Tui<B> {
    /// Constructs a new instance of [`Tui`].
    pub fn new(terminal: Terminal<B>, events: EventHandler) -> Self {
        Self { terminal, events }
    }

    /// Initializes the terminal interface.
    ///
    /// It enables the raw mode and sets terminal properties.
    pub fn init(&mut self) -> AppResult<()> {
        terminal::enable_raw_mode()?;
        crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?;

        // Define a custom panic hook to reset the terminal properties.
        // This way, you won't have your terminal messed up if an unexpected error happens.
        let panic_hook = panic::take_hook();
        panic::set_hook(Box::new(move |panic| {
            Self::reset().expect("failed to reset the terminal");
            panic_hook(panic);
        }));

        self.terminal.hide_cursor()?;
        self.terminal.clear()?;
        Ok(())
    }

    /// [`Draw`] the terminal interface by [`rendering`] the widgets.
    ///
    /// [`Draw`]: ratatui::Terminal::draw
    /// [`rendering`]: crate::ui:render
    pub fn draw(&mut self, app: &mut App) -> AppResult<()> {
        self.terminal.draw(|frame| ui::render(app, frame))?;
        Ok(())
    }

    /// Resets the terminal interface.
    ///
    /// This function is also used for the panic hook to revert
    /// the terminal properties if unexpected errors occur.
    fn reset() -> AppResult<()> {
        terminal::disable_raw_mode()?;
        crossterm::execute!(io::stderr(), LeaveAlternateScreen, DisableMouseCapture)?;
        Ok(())
    }

    /// Exits the terminal interface.
    ///
    /// It disables the raw mode and reverts back the terminal properties.
    pub fn exit(&mut self) -> AppResult<()> {
        Self::reset()?;
        self.terminal.show_cursor()?;
        Ok(())
    }
}

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

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

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

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

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

答案在下一章, 也就是 p3
~~> 传送门: p3-更多概念

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


更多概念

在上一小节里, 我们接触了 原始模式(raw_mode), 管道(pipeline), 标准输出与标准错误(stdout && stderr)
有些小伙伴可能对这些不是太清楚, 所以我多分了一章专门说明这些概念

~~> 传送门: p3-更多概念

如果你对这些概念不是很清楚的话, 那我非常非常推荐你阅读一下, 可以算是必读的东西了
不过因为这些概念与本章的架构说明, 主题上并非太相关, 而且放一起的话篇幅有点大, 所以我就将它们分开了


结尾

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

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 实现的小项目吧, 像是自定义组件, 处理用户输入等内容, 则会搭配着这些项目来说明

谢谢观看!