【Rust】Ratatui で TUI アプリを作る - ターミナル UI 開発入門

PUBLISHED 2026-02-06

TUI(Text User Interface)は、ターミナル上で動作するインタラクティブな UI です。RustRatatui ライブラリを使えば、高速で堅牢な TUI アプリケーションを構築できます。この記事では、Ratatui の基礎から実践的な Todo アプリの構築までを解説します。

TUI とは

TUI は Text User Interface(Terminal User Interface)の略で、ターミナル上でキーボード操作によってインタラクティブに操作できる UI のことです。

有名な TUI アプリケーションの例を挙げます。

  • vim / neovim - テキストエディタ
  • htop - プロセスモニター
  • lazygit - Git 操作ツール
  • k9s - Kubernetes 管理ツール
✅ TUI の強み
  • SSH 経由でもリモート操作可能
  • 軽量でリソース消費が少ない
  • キーボードだけで高速操作
  • 自動化やスクリプトとの相性が良い
❌ TUI の弱み
  • マウス操作に慣れたユーザーには学習コストがある
  • 画像や複雑なレイアウトの表現に限界がある
  • ターミナルの種類によって表示が異なる場合がある

TUI 開発のライブラリ比較

主要な言語ごとの TUI ライブラリを比較します。

言語ライブラリ特徴
RustRatatui高速、型安全、ウィジェット豊富
GoBubbleteaElm アーキテクチャ、エコシステムが充実
PythonTextualCSS でスタイリング可能
Node.jsInkReact 構文で記述可能
🦀 なぜ Rust + Ratatui なのか

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
クレート役割
ratatuiUI 描画(ウィジェット、レイアウト)
crosstermターミナル制御(キー入力、画面操作)
%%{init: {'theme':'neutral'}}%% graph LR A[ユーザー入力] --> B[crossterm] B --> C[アプリ状態更新] C --> D[ratatui] D --> E[ターミナル描画] E --> A

基本構造

TUI アプリは「初期化 → メインループ → 終了処理」の3段階で構成されます。

1
ターミナルの初期化

Raw モードを有効化し、代替スクリーンに切り替えます。これにより通常のターミナル出力とは別の画面で描画できます。

2
メインループ

「描画 → 入力待ち → 状態更新」を繰り返します。ゲームループと似た構造です。

3
ターミナルの復元

アプリ終了時に 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)
%%{init: {'theme':'neutral'}}%% graph TD A[main.rs] --> B[app.rs - 状態管理] A --> C[ui.rs - 描画] A --> D[event.rs - 入力処理] D -->|状態更新| B B -->|参照| C

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

参考文献

まとめ

この記事のポイント
  • TUI はターミナル上で動作するインタラクティブな UI
  • Ratatui は Rust で最も人気のある TUI ライブラリ
  • crossterm と組み合わせてキー入力・画面制御を行う
  • Layout で画面を分割し、ウィジェットを配置する
  • Model-View-Update パターンで構造化すると保守しやすい
  • パニック時の復元処理 を忘れずに実装する
CATEGORY
円