【Rust】Ratatui で TUI アプリを作る - ターミナル UI 開発入門
TUI(Text User Interface)は、ターミナル上で動作するインタラクティブな UI です。Rust の Ratatui ライブラリを使えば、高速で堅牢な TUI アプリケーションを構築できます。この記事では、Ratatui の基礎から実践的な Todo アプリの構築までを解説します。
TUI とは
TUI は Text User Interface(Terminal User Interface)の略で、ターミナル上でキーボード操作によってインタラクティブに操作できる UI のことです。
有名な TUI アプリケーションの例を挙げます。
- vim / neovim - テキストエディタ
- htop - プロセスモニター
- lazygit - Git 操作ツール
- k9s - Kubernetes 管理ツール
- SSH 経由でもリモート操作可能
- 軽量でリソース消費が少ない
- キーボードだけで高速操作
- 自動化やスクリプトとの相性が良い
- マウス操作に慣れたユーザーには学習コストがある
- 画像や複雑なレイアウトの表現に限界がある
- ターミナルの種類によって表示が異なる場合がある
TUI 開発のライブラリ比較
主要な言語ごとの TUI ライブラリを比較します。
| 言語 | ライブラリ | 特徴 |
|---|---|---|
| Rust | Ratatui | 高速、型安全、ウィジェット豊富 |
| Go | Bubbletea | Elm アーキテクチャ、エコシステムが充実 |
| Python | Textual | CSS でスタイリング可能 |
| Node.js | Ink | React 構文で記述可能 |
Rust はメモリ安全性とパフォーマンスを両立する言語です。Ratatui は Rust のエコシステムで最も人気のある TUI ライブラリで、GitHub スター数も多く、活発に開発が続いています。
環境構築
Rust のインストール
まだ Rust をインストールしていない場合は、rustup を使います。
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Windows の場合は rustup.rs からインストーラをダウンロードします。
プロジェクト作成
cargo new my-tui-app
cd my-tui-app
依存クレートの追加
cargo add ratatui crossterm
| クレート | 役割 |
|---|---|
ratatui | UI 描画(ウィジェット、レイアウト) |
crossterm | ターミナル制御(キー入力、画面操作) |
基本構造
TUI アプリは「初期化 → メインループ → 終了処理」の3段階で構成されます。
Raw モードを有効化し、代替スクリーンに切り替えます。これにより通常のターミナル出力とは別の画面で描画できます。
「描画 → 入力待ち → 状態更新」を繰り返します。ゲームループと似た構造です。
アプリ終了時に Raw モードを無効化し、元のターミナル画面に戻します。
最小構成のコード
use std::io;
use crossterm::{
event::{self, Event, KeyCode},
terminal::{
disable_raw_mode, enable_raw_mode,
EnterAlternateScreen, LeaveAlternateScreen,
},
ExecutableCommand,
};
use ratatui::{
prelude::*,
widgets::{Block, Borders, Paragraph},
};
fn main() -> io::Result<()> {
// 1. ターミナル初期化
enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?;
let mut terminal = Terminal::new(
CrosstermBackend::new(io::stdout())
)?;
// 2. メインループ
loop {
// 描画
terminal.draw(|frame| {
let area = frame.area();
let block = Block::default()
.title(" My TUI App ")
.borders(Borders::ALL);
let text = Paragraph::new(
"Hello, TUI! Press 'q' to quit."
)
.block(block);
frame.render_widget(text, area);
})?;
// 入力処理
if let Event::Key(key) = event::read()? {
if key.code == KeyCode::Char('q') {
break;
}
}
}
// 3. ターミナル復元
disable_raw_mode()?;
io::stdout().execute(LeaveAlternateScreen)?;
Ok(())
}
実行します。
cargo run
ターミナルに枠線付きのテキストが表示され、q キーで終了できます。
レイアウト
Ratatui では Layout を使って画面を分割します。Web の Flexbox に似た考え方です。
縦分割
use ratatui::prelude::*;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // ヘッダー: 固定3行
Constraint::Min(0), // メイン: 残り全部
Constraint::Length(3), // フッター: 固定3行
])
.split(frame.area());
横分割
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30), // 左: 30%
Constraint::Percentage(70), // 右: 70%
])
.split(frame.area());
Constraint の種類
| 種類 | 説明 | 例 |
|---|---|---|
Length(n) | 固定 n 行/列 | Length(3) |
Min(n) | 最小 n 行/列 | Min(0) |
Max(n) | 最大 n 行/列 | Max(10) |
Percentage(n) | n% の割合 | Percentage(50) |
Ratio(a, b) | a/b の比率 | Ratio(1, 3) |
主要ウィジェット
Ratatui には豊富なウィジェットが用意されています。
Paragraph - テキスト表示
use ratatui::widgets::{Block, Borders, Paragraph};
let paragraph = Paragraph::new("テキスト内容")
.block(
Block::default()
.title("タイトル")
.borders(Borders::ALL)
)
.style(Style::default().fg(Color::Cyan))
.wrap(Wrap { trim: true });
frame.render_widget(paragraph, area);
List - 選択リスト
use ratatui::widgets::{List, ListItem, ListState};
let items: Vec<ListItem> = vec![
ListItem::new("Item 1"),
ListItem::new("Item 2"),
ListItem::new("Item 3"),
];
let list = List::new(items)
.block(Block::default().title("リスト").borders(Borders::ALL))
.highlight_style(
Style::default().bg(Color::DarkGray).bold()
)
.highlight_symbol("▶ ");
let mut state = ListState::default();
state.select(Some(0)); // 0番目を選択状態に
frame.render_stateful_widget(list, area, &mut state);
Table - テーブル
use ratatui::widgets::{Table, Row, Cell};
let rows = vec![
Row::new(vec![
Cell::from("Alice"), Cell::from("30"), Cell::from("Tokyo"),
]),
Row::new(vec![
Cell::from("Bob"), Cell::from("25"), Cell::from("Osaka"),
]),
];
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Length(5),
Constraint::Length(10),
],
)
.header(
Row::new(vec!["Name", "Age", "City"])
.style(Style::default().bold())
)
.block(Block::default().title("テーブル").borders(Borders::ALL));
frame.render_widget(table, area);
Gauge - プログレスバー
use ratatui::widgets::Gauge;
let gauge = Gauge::default()
.block(Block::default().title("Progress").borders(Borders::ALL))
.gauge_style(Style::default().fg(Color::Green))
.percent(65);
frame.render_widget(gauge, area);
Ratatui には他にも Tabs(タブ切り替え)、Chart(グラフ描画)、Canvas(自由描画)、Sparkline(スパークライン)など多数のウィジェットがあります。公式ドキュメントで全一覧を確認できます。
実践: Todo アプリを作る
ここからは実践的な Todo アプリを構築します。
プロジェクト構成
src/
├── main.rs # エントリポイント
├── app.rs # アプリ状態(Model)
├── ui.rs # 描画ロジック(View)
└── event.rs # キー入力処理(Update)
app.rs - 状態管理
// src/app.rs
pub struct TodoItem {
pub text: String,
pub done: bool,
}
pub enum InputMode {
Normal,
Editing,
}
pub struct App {
pub items: Vec<TodoItem>,
pub selected: usize,
pub input: String,
pub input_mode: InputMode,
pub running: bool,
}
impl App {
pub fn new() -> Self {
Self {
items: vec![
TodoItem {
text: "Rust を学ぶ".into(),
done: false,
},
TodoItem {
text: "Ratatui でアプリを作る".into(),
done: false,
},
],
selected: 0,
input: String::new(),
input_mode: InputMode::Normal,
running: true,
}
}
pub fn next(&mut self) {
if !self.items.is_empty() {
self.selected =
(self.selected + 1) % self.items.len();
}
}
pub fn prev(&mut self) {
if !self.items.is_empty() {
self.selected = self
.selected
.checked_sub(1)
.unwrap_or(self.items.len() - 1);
}
}
pub fn toggle_done(&mut self) {
if let Some(item) = self.items.get_mut(self.selected)
{
item.done = !item.done;
}
}
pub fn add_item(&mut self) {
if !self.input.is_empty() {
self.items.push(TodoItem {
text: self.input.drain(..).collect(),
done: false,
});
}
}
pub fn delete_item(&mut self) {
if !self.items.is_empty() {
self.items.remove(self.selected);
if self.selected >= self.items.len()
&& self.selected > 0
{
self.selected -= 1;
}
}
}
}
ui.rs - 描画ロジック
// src/ui.rs
use ratatui::{prelude::*, widgets::*};
use crate::app::{App, InputMode};
pub fn draw(frame: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // タイトル
Constraint::Length(3), // 入力欄
Constraint::Min(0), // リスト
Constraint::Length(3), // ヘルプ
])
.split(frame.area());
// タイトル
let title = Paragraph::new("📝 My Todo App")
.style(Style::default().fg(Color::Cyan).bold())
.block(
Block::default().borders(Borders::ALL),
);
frame.render_widget(title, chunks[0]);
// 入力欄
let input_style = match app.input_mode {
InputMode::Normal => Style::default(),
InputMode::Editing => {
Style::default().fg(Color::Yellow)
}
};
let input = Paragraph::new(app.input.as_str())
.style(input_style)
.block(
Block::default()
.title("New Todo (press 'a' to add)")
.borders(Borders::ALL),
);
frame.render_widget(input, chunks[1]);
// Todo リスト
let items: Vec<ListItem> = app
.items
.iter()
.enumerate()
.map(|(i, item)| {
let icon = if item.done { "☑" } else { "☐" };
let style = if item.done {
Style::default().fg(Color::DarkGray)
} else if i == app.selected {
Style::default().fg(Color::Cyan).bold()
} else {
Style::default()
};
ListItem::new(format!(" {} {}", icon, item.text))
.style(style)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(format!(
"Todos ({}/{})",
app.items
.iter()
.filter(|i| i.done)
.count(),
app.items.len()
))
.borders(Borders::ALL),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
let mut state = ListState::default();
state.select(Some(app.selected));
frame.render_stateful_widget(
list,
chunks[2],
&mut state,
);
// ヘルプ
let help_text = match app.input_mode {
InputMode::Normal => {
"j/k: 移動 | Enter: 完了切替 | a: 追加 | d: 削除 | q: 終了"
}
InputMode::Editing => {
"Enter: 確定 | Esc: キャンセル"
}
};
let help = Paragraph::new(help_text)
.style(Style::default().fg(Color::DarkGray))
.block(
Block::default().borders(Borders::ALL),
);
frame.render_widget(help, chunks[3]);
}
event.rs - 入力処理
// src/event.rs
use crossterm::event::{self, Event, KeyCode};
use crate::app::{App, InputMode};
use std::io;
pub fn handle_events(app: &mut App) -> io::Result<()> {
if let Event::Key(key) = event::read()? {
match app.input_mode {
InputMode::Normal => match key.code {
KeyCode::Char('q') => {
app.running = false;
}
KeyCode::Char('j') | KeyCode::Down => {
app.next();
}
KeyCode::Char('k') | KeyCode::Up => {
app.prev();
}
KeyCode::Enter => {
app.toggle_done();
}
KeyCode::Char('a') => {
app.input_mode = InputMode::Editing;
}
KeyCode::Char('d') => {
app.delete_item();
}
_ => {}
},
InputMode::Editing => match key.code {
KeyCode::Enter => {
app.add_item();
app.input_mode = InputMode::Normal;
}
KeyCode::Esc => {
app.input.clear();
app.input_mode = InputMode::Normal;
}
KeyCode::Char(c) => {
app.input.push(c);
}
KeyCode::Backspace => {
app.input.pop();
}
_ => {}
},
}
}
Ok(())
}
main.rs - エントリポイント
// src/main.rs
mod app;
mod event;
mod ui;
use std::io;
use crossterm::{
terminal::{
disable_raw_mode, enable_raw_mode,
EnterAlternateScreen, LeaveAlternateScreen,
},
ExecutableCommand,
};
use ratatui::prelude::*;
fn main() -> io::Result<()> {
// 初期化
enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?;
let mut terminal = Terminal::new(
CrosstermBackend::new(io::stdout()),
)?;
// アプリ状態
let mut app = app::App::new();
// メインループ
while app.running {
terminal.draw(|frame| {
ui::draw(frame, &app);
})?;
event::handle_events(&mut app)?;
}
// 復元
disable_raw_mode()?;
io::stdout().execute(LeaveAlternateScreen)?;
Ok(())
}
この Todo アプリでは vim 風のキーバインド(j/k で移動)を採用しています。必要に応じて矢印キーへの対応も event.rs で追加済みです。
スタイリング
Ratatui ではテキストやウィジェットにスタイルを適用できます。
色の指定
// 基本色
Style::default().fg(Color::Red)
Style::default().bg(Color::Blue)
// RGB 指定
Style::default().fg(Color::Rgb(255, 165, 0))
修飾子
Style::default()
.fg(Color::Cyan)
.bold()
.italic()
.underlined()
使える修飾子一覧
| 修飾子 | 説明 |
|---|---|
.bold() | 太字 |
.italic() | 斜体 |
.underlined() | 下線 |
.dim() | 薄暗い表示 |
.reversed() | 前景色と背景色を反転 |
.crossed_out() | 取り消し線 |
よくある間違い
Raw モードの復元忘れ
パニック時にターミナルが壊れた状態になることがあります。std::panic::set_hook で対策できます。
use std::panic;
fn main() -> io::Result<()> {
enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?;
// パニック時の復元処理
let original_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic_info| {
let _ = disable_raw_mode();
let _ = io::stdout()
.execute(LeaveAlternateScreen);
original_hook(panic_info);
}));
// ... アプリケーションコード
Ok(())
}
パニック時の復元処理を入れないと、アプリがクラッシュした際にターミナルが操作不能になります。reset コマンドで復旧できますが、事前に対策しておくことを推奨します。
描画の最適化
毎フレーム全体を再描画するのではなく、差分のみを描画する仕組みが Ratatui には組み込まれています。terminal.draw() は自動的に差分描画を行うため、特別な対応は不要です。
便利なクレート
TUI 開発で役立つ追加クレートを紹介します。
| クレート | 用途 |
|---|---|
color-eyre | エラーハンドリングの改善 |
tui-textarea | テキスト入力エリア |
tui-input | シンプルな入力欄 |
better-panic | パニック時の表示改善 |
serde + serde_json | データの保存・読み込み |
tokio | 非同期処理(API 通信など) |
cargo add color-eyre tui-textarea
参考文献
- Ratatui 公式サイト
- Ratatui GitHub リポジトリ
- Ratatui テンプレート集
- crossterm ドキュメント
- The Rust Programming Language(日本語)
まとめ
- TUI はターミナル上で動作するインタラクティブな UI
- Ratatui は Rust で最も人気のある TUI ライブラリ
- crossterm と組み合わせてキー入力・画面制御を行う
- Layout で画面を分割し、ウィジェットを配置する
- Model-View-Update パターンで構造化すると保守しやすい
- パニック時の復元処理 を忘れずに実装する