🧊

`ratatui`と`tokio`で作るTUIの基本

というのを最近模索していて、その成果としてのメモ。

作ったのはまたもASTビューワーで、ディレクトリを指定すれば、OXCでパースしたASTを見れるという簡単なやつ。

leaysgur/oxc-parser-tui https://github.com/leaysgur/oxc-parser-tui

ratatuicrossterm

RustでTUI作るならコレというやつ。

ratatui/ratatui: A Rust crate for cooking up terminal user interfaces (TUIs) 👨‍🍳🐀 https://ratatui.rs https://github.com/ratatui/ratatui

現時点でのバージョンは0.29で、もうすぐ0.30になるとのこと。

役割としては、

  • TUIの起動
  • TUI上で描画したいコンポーネント
    • ParagraphBlockListScrollbarもある
  • コンポーネントのレイアウト
    • FlexとかLengthとかPercentageとかでエリアを区切ってくイメージ
  • テキストの色やスタイル

というあたり。

キーボード入力などに対応するのは、内部的にも依存してるcrosstermという別のバックエンドになる。(このバックエンドは別の実装に差し替えられるけど、デファクトがcrosstermらしい)

crossterm-rs/crossterm: Cross platform terminal library rust https://github.com/crossterm-rs/crossterm

コンポーネントのレンダリング

レンダリングはゲーム開発のそれみたく、メインのloopの中で常に呼ぶパターンが一般的らしい。

loop {
    terminal.draw(|frame| {
        if state.condition {
            frame.render_widget(SomeWidget::new(), layout);
        } else {
            frame.render_widget(AnotherWidget::new(), layout);
        }
    })?;
}

手動でタイマーを管理してFPSをキープするもよし、手動で再レンダリングしてもたぶんいい。

render_widget()は、Widget taritを実装していればなんでもよいので、impl Widget for &App { fn render() {} }してるパターンをよく見る。 もちろん純関数にしてしまってもいい。

ScrollbarListでスクロール位置を保持する必要がある場合は、render_stateful_widget()という特別なやつを使うらしく、このために&mutScrollStateListStateを渡すデザインになってた。

別のところで保持するデータから毎フレームコピーするようにしても実装できるけど、リストで現在地インデックスが要素数を超えないようにするみたいな実装を手動でやらないといけなくなるようだった。

WebのGUIから入ると、TUIはできることが限られていてすごく息苦しい。

  • JSXもHTMLでもない懐かしいifとメソッドチェーンの世界
    • 当然{#await}もなければRSCもない
  • z-indexも::backdropもないからUI表現も乏しい
  • もちろんinput要素もない
  • テキストの自動回りこみはParagraphで明示したときだけ
    • 独自UIを作るなら、だいたいは平面レイアウトを自作することになる
  • マウスのサポートもあるが、UXはお察し

Signalsがほしい・・・宣言的UIがほしい・・・。

tokioでノンブロッキング

TUIを実行してるスレッド上で重たい処理を走らせると、UIの表示や入力にラグが出てしまう。(このへんはGUIのフロントエンドでも同じ)

なので、重たいタスクやあらゆるイベント待受は非同期にして、ノンブロッキングにしたいとなる。

ここで登場するのが、Rustの非同期ランタイムのデファクトであるtokioということらしい。

  • tokio::spawnで実行主体は別タスクにしておき
  • tokio::sync::{mpsc, watch}なんかを使って連携し
  • タイマーやキーイベントの入力を待ち受けて、重たい非同期処理するなり、単にマッピングして受け流してチャンネルのtxを叩くなり
  • 大元ではさっきのようなloopの中で各種rxをtokio::select!で拾って、別の同期処理で状態を更新

というイメージ。

とにかくイベントが飛び交うあの頃のWebSocket的な世界線だった。

  • メインで密にイベントを管理して、必要に応じて下層に分配するパターンか
  • 下層それぞれでイベントを待ち受けて、疎にやる前提のパターンか

このへんは作りたいものに応じてよしなにする。けど、後者に振り切るのがいつも難しいのがUIよね・・・。

もちろんFluxへの言及もあるけど、個人的にFlux(Redux)ファンじゃないので悩ましい。

Flux Architecture | Ratatui https://ratatui.rs/concepts/application-patterns/flux-architecture/

Signalsがほしい・・・宣言的UIがほしい・・・。(二度目)

細かいコード

rataui::run()

バージョン0.30で追加された便利なやつ。

feat: add ratatui::run() method by joshka · Pull Request #1707 · ratatui/ratatui https://github.com/ratatui/ratatui/pull/1707

コレは、

  • rawモードの切り替え
  • AlternateScreenの入と出
  • crosstermTerminalの初期化

というようなボイラープレートをよしなにやってくれるクロージャ。

ただasyncではないので、#[tokio::main]と一緒には使えない。

ratatui::run(|terminal| {
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap();
    runtime.block_on(run(terminal));
});

async fn run<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>) {}

非同期キーイベント

別途で依存を追加したら使えた。

crossterm = { version = "0.28.1", features = ["event-stream"] }
futures = "0.3.31"
tokio = { version = "1.40.0", features = ["full"] }

この状態でなら、EventStreamが使えるようになる。

use futures::StreamExt;
use ratatui::crossterm::event::{EventStream, KeyEvent};

tokio::spawn(async move {
    let mut event_stream = EventStream::new();
    while let Some(Ok(ev)) = event_stream.next().await {
        // Should check press event for Windows
        let Some(KeyEvent { modifiers, code, .. }) = ev.as_key_press_event()
        else {
            continue;
        };

        // ...
    }
}

ratatui::crosstermcrosstermの本家の両方があるのがとても紛らわしい。

templates/event-driven-async/template at main · ratatui/templates https://github.com/ratatui/templates/tree/main/event-driven-async/template