というのを最近模索していて、その成果としてのメモ。
作ったのはまたもASTビューワーで、ディレクトリを指定すれば、OXCでパースしたASTを見れるという簡単なやつ。
leaysgur/oxc-parser-tui https://github.com/leaysgur/oxc-parser-tui
ratatui
とcrossterm
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上で描画したいコンポーネント
Paragraph
やBlock
、List
やScrollbar
もある
- コンポーネントのレイアウト
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() {} }
してるパターンをよく見る。
もちろん純関数にしてしまってもいい。
Scrollbar
やList
でスクロール位置を保持する必要がある場合は、render_stateful_widget()
という特別なやつを使うらしく、このために&mut
でScrollState
やListState
を渡すデザインになってた。
別のところで保持するデータから毎フレームコピーするようにしても実装できるけど、リストで現在地インデックスが要素数を超えないようにするみたいな実装を手動でやらないといけなくなるようだった。
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
の入と出crossterm
のTerminal
の初期化
というようなボイラープレートをよしなにやってくれるクロージャ。
ただ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::crossterm
とcrossterm
の本家の両方があるのがとても紛らわしい。
templates/event-driven-async/template at main · ratatui/templates https://github.com/ratatui/templates/tree/main/event-driven-async/template