👩‍🍳

Ratatuiのチュートリアルをやる: その1

に公開

Ratatuiを使って作りたいツールが出てきたので、Ratatuiの使い方を勉強することにした。Ratatuiにはいくつかのチュートリアルがあり、これらをこなすことでRatatuiの使い方や中に登場する概念を一通り理解できるようになっている。このチュートリアルをやってみる。

https://ratatui.rs/tutorials/

Ratatui

Ratatui(ラタトゥイ)はRustでTUI(Terminal UI)ツールを作ることができるクレート(ライブラリ)だ。名前の由来はおそらく「ラタトゥイユ」だと思う。このクレートは後述するようにtui-rsをフォークして作られたものであり、現状見かける多くのRust製TUIツールはだいたいこれを使って実装されていると思う。なので、RustでTUIを作るときは第一候補に上がる。

Ratatuiはもともとはtui-rsというRust向けのTUIクレートの後継として2023年ごろにフォークされ、開発が進められた。[1]フォーク後も積極的に活動が行われており、2025年11月現在でも活発に開発やコミュニティ活動が行われている。たとえば最近だと作者が来日してハンズオンを東京で行うなど、かなりコミュニティ活動にも力を入れているのが特徴的だ。[2]とくにOSSにとって強いコミュニティ基盤は、OSSそれ自体の持続性や安定的な財政基盤の確立などさまざまなメリットがある。そこに力を入れているという点で非常に魅力的に感じる。

RustでTUIツールを作る最大のメリットはその速度にある。これは私がそうしたRust製のツールを使用している中で感じる主観であるが、Rust製のTUIツールはとにかく動作がキビキビして安定的であり、途中でラグが発生するなどのカクツキをほとんど感じることがない。とにかく高速に動作し、動作が安定的なツールを作りたい場合にはRustを選択するのは非常に理にかなっていると私は思う。他の言語で書かれたツールでは実現できない安定的な高速動作を見込めるのが、Rust製TUIの最大のメリットではないかと考えている。

Ratatuiで実装された代表的なツールは数多くある。たとえば下記のshowcaseというページでは、実際にRatatuiで実装されたTUIツールが集められている。このshowcaseの中だと、私はbottom、television、yaziあたりにお世話になっている。また、awesome-ratatuiというGitHubリポジトリがあり、そこにも大量のRatatui製アプリが集められている。

RatatuiはRustでリッチなUIをターミナル上に実現できる最適なクレートだと考えている。今回はそういうわけで、Ratatuiのチュートリアルを通じてRatatuiの書き心地を学んでいく。

チュートリアル

最初は簡単なカウンターアプリを実装してみるチュートリアルだ。仕様は非常に単純で、アプリを起動後「←」を入力するとカウントを減らし、「→」を入力するとカウントを増やす。

https://ratatui.rs/tutorials/counter-app/

チュートリアルそれ自体は上から順番にこなしていくと完成させられる。今回この記事では、チュートリアルをやっていて重要そうだと思った話を簡単に補足情報的にまとめておく。

状態管理

UIツールにあるあるな話ではあるが、UIの中で持つ状態をどう管理するかがまず焦点になる。今回はカウンターアプリなので、現在のカウントがまずは状態の候補になる。加えて、アプリを終了させるかどうかの判定も持たせる。今回実装するUIアプリは、起動後whileによる条件判定を行い、条件に当てはまらなければ描画を続けるというループを回し続ける。その際利用する状態だ。

今回は状態をひとつの構造体でまとめて管理する。Appはアプリケーション全体の状態管理の役割を担いつつ、自身も最も大元のウィジェット(後述)として扱われる。

#[derive(Debug, Default)]
pub struct App {
    // カウンター
    counter: u8,
    // アプリが終了状態になるかどうか
    exit: bool,
}

関数の設計

今回のアプリは、大まかに概念をわけるとすれば3つの関数から構成される。それ以外にもいくつかのプライベート関数が用意されるが、どれもこの3つのうちどれかの機能を補助するために用意される。

  • アプリケーションの起動: runという関数を用意して実装する。この関数は中でループを走らせ、アプリケーションの終了判定がなされるまでずっと描画し、入力されるキーを待ち受けつつキーに応じて処理をハンドルする。
  • アプリケーションの描画(render): drawという関数を用いて行う。この関数の中では、後述するFramerender_widgetという関数を呼び出し、実装された「ウィジェット」を描画する。
  • 入力キーのハンドリング: TUIツールの操作の中心はキーボードからの入力である。handle_eventsという関数を用意し、入力されるキーを読み込んでキーに応じたイベントを発火させる。

後者2つはrun関数の中で次のように呼び出される。その際、先ほど説明したAppexitを用い、アプリケーションの終了判定がなされていないかを確認している。

// -- snippet --
impl App {
    pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
        while !self.exit {
            terminal.draw(|frame| self.draw(frame))?;
            self.handle_events().wrap_err("handle events failed")?;
        }
        Ok(())
    }
// ...
}

TerminalとFrame

DefaultTerminalというのは、デフォルト設定のターミナルであることを意味している。Ratatuiの中でTerminalというのはユーザーのターミナルにFrameを描画するために使用されるインタフェースのことを指している。デフォルトの設定では、ターミナルの描画のためのバックエンドとしてCrossterm[3]というクレートを使用するという設定がなされている。また、出力先は標準出力(stdout)となる。

pub type DefaultTerminal = Terminal<CrosstermBackend<Stdout>>;

FrameはRatatuiの描画における中心的な役割を果たす。描画に関するさまざまな情報ないしはコンテクストを保持しており、ターミナルへの描画はこの構造体の管理するバッファなどを通じて行われる。Frameは中に(これも後述する)ウィジェットを複数持つことができる。複数のウィジェットを組み合わせてよく見るTUIツールを実装する。FrameTerminal::drawのクロージャを通じて渡される。概念図を示すと下記のようになる。

┌──────────────────────────────┐
│         Frame (1 frame)      │
│  ┌───────────────┐           │
│  │ Block widget  │           │
│  └───────────────┘           │
│  ┌───────────────┐           │
│  │ List widget   │           │
│  └───────────────┘           │
└──────────────────────────────┘

Frameへのウィジェットの描画は、今回のチュートリアルでは次のような処理を記述した。名前の通りではあるが、render_widget関数にAppそれ自体とFrameの領域情報を保持するareaを渡す。

impl App {
    // --- snippet ---
    fn draw(&self, frame: &mut Frame) {
        frame.render_widget(self, frame.area());
    }

Widget

ウィジェットとはユーザーインタフェースの基本単位だ。TUIでは何かデータをリストやテーブルとして表示したり、あるいは文字列を表示するための領域を用意することがあるが、あれらをすべてウィジェットとして扱っている。最終的には描画領域Rectと描画バッファBufferに結果を渡し、ターミナル上にUIを描画する。

今回のチュートリアルではAppをいわゆるルートのウィジェットとして扱っている。そのためrender_widget関数の第一引数に引き渡せるわけだが、これをできるのはWidgetAppに対して実装したためだ。Appは次のように内部にさまざまなウィジェットを持っている。

impl Widget for &App {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let title = Line::from(" Counter App Tutorial ".bold());
        let instructions = Line::from(vec![
            " Decrement ".into(),
            "<Left>".blue().bold(),
            " Increment ".into(),
            "<Right>".blue().bold(),
            " Quit ".into(),
            "<Q> ".blue().bold(),
        ]);
        let block = Block::bordered()
            .title(title.centered())
            .title_bottom(instructions.centered())
            .border_set(border::THICK);

        let counter_text = Text::from(vec![Line::from(vec![
            "Value: ".into(),
            self.counter.to_string().yellow(),
        ])]);

        Paragraph::new(counter_text)
            .centered()
            .block(block)
            .render(area, buf);
    }
}

ウィジェットそれ自体に状態を持たせて細かくスコープを絞って状態管理させることもできそうだ。Appでグローバル管理するのもアプリケーションの規模が小さいうちは悪くなさそうだが、いつかスコープを絞った状態管理が欲しくなる。その場合、StatefulWidgetというトレイトが提供されており、それを使えるらしい。さらに踏み込んだ話は下記のページにまとまっている。

https://ratatui.rs/concepts/widgets/

イベントハンドリング

今回はキーボードからの入力イベントをハンドリングして、アプリケーションのカウント数を書き換えたり、アプリケーションを終了させたりする操作を実装する。

まずイベントを読み取り、読み取ったイベントのうちキーボードからの入力だった場合のみ特定の操作を行うよう実装する。キーとなるのはevent::read()という関数で、この関数がイベントを受け取り、受け取ったイベントの情報を返す。後続処理でパターンマッチをかけ、キーボードからの入力Event::Keyかつキーが押されたことKeyEventKind::Pressを検知できた場合、キー単位でのハンドリング用の関数(handle_key_event)を呼び出す。

impl App {
    // --- snippet ---
    fn handle_events(&mut self) -> Result<()> {
        match event::read()? {
            Event::Key(key_event) if key_event.kind == KeyEventKind::Press => self
                .handle_key_event(key_event)
                .wrap_err_with(|| format!("handling key event failed:\n{key_event:#?}"))?,
            _ => {}
        };
        Ok(())
}

注意点として、event::read()関数はブロッキング処理なことがあげられる。つまり次のイベントが来るまで処理をストップさせる。何かイベントが起こらない限り永遠にそこでブロックさせる。指定時間でタイムアウトさせて次のイベントを受け付けられるようにするようなノンブロッキング処理を行いたい場合、pollという関数と組み合わせて行うようドキュメントに指示があった。

/// Non-blocking read:
///
/// ```no_run
/// use std::time::Duration;
/// use std::io;
///
/// use crossterm::event::{read, poll};
///
/// fn print_events() -> io::Result<bool> {
///     loop {
///         if poll(Duration::from_millis(100))? {
///             // It's guaranteed that `read` won't block, because `poll` returned
///             // `Ok(true)`.
///             println!("{:?}", read()?);
///         } else {
///             // Timeout expired, no `Event` is available
///         }
///     }
/// }
/// ```

拾えるイベントの一覧は次のようになっている。

pub enum Event {
    /// The terminal gained focus
    FocusGained,
    /// The terminal lost focus
    FocusLost,
    /// A single key event with additional pressed modifiers.
    Key(KeyEvent),
    /// A single mouse event with additional pressed modifiers.
    Mouse(MouseEvent),
    /// A string that was pasted into the terminal. Only emitted if bracketed paste has been
    /// enabled.
    #[cfg(feature = "bracketed-paste")]
    Paste(String),
    /// An resize event with new dimensions after resize (columns, rows).
    /// **Note** that resize events can occur in batches.
    Resize(u16, u16),
}

テストコード

ユニットテストも割と簡単に実装できる。ターミナル描画に関する最終的な状態はBufferに保存されており、これがテストにおける中心的な役割を果たす。Buffer::emptyで空のバッファを含む描画領域を用意しておき、それをテスト対象に渡す。最後はBufferをassertすれば正しくUIを描画できそうかを判定できる。

    #[test]
    fn render() {
        let app = App::default();
        let mut buf = Buffer::empty(Rect::new(0, 0, 50, 4));

        app.render(buf.area, &mut buf);

        let mut expected = Buffer::with_lines(vec![
            "┏━━━━━━━━━━━━━ Counter App Tutorial ━━━━━━━━━━━━━┓",
            "┃                    Value: 0                    ┃",
            "┃                                                ┃",
            "┗━ Decrement <Left> Increment <Right> Quit <Q> ━━┛",
        ]);
        let title_style = Style::new().bold();
        let counter_style = Style::new().yellow();
        let key_style = Style::new().blue().bold();
        expected.set_style(Rect::new(14, 0, 22, 1), title_style);
        expected.set_style(Rect::new(28, 1, 1, 1), counter_style);
        expected.set_style(Rect::new(13, 3, 6, 1), key_style);
        expected.set_style(Rect::new(30, 3, 7, 1), key_style);
        expected.set_style(Rect::new(43, 3, 4, 1), key_style);

        assert_eq!(buf, expected);
    }

チュートリアル内の誤り

2025年11月時点ではチュートリアル内にひとつ、最新版のRatatuiに追従できていないために起きているドキュメントの不整合があることが確認できた。なおチュートリアルには注意書きが書かれてはおり、それを読んで気づくこともできる。

元々のチュートリアルではtui.rsというファイルを用意し、その中にターミナルの状態を復帰するリストア用の関数やパニック時のハンドラを実装していたらしい。最新のRatatuiではまずパニックハンドラは不要になり(init時にセットされるようになったらしい)、エラーが発生した場合に備えてResult型を返す関数はratatui::try_restore()で肩代わりできるようになった。これによりtui.rsの内容はおそらく実装不要になっている。[4]

最新の状況に追従できている実装を下記に示しておく。

fn main() -> Result<()> {
    color_eyre::install()?;
    // Initialize terminal
    let mut terminal = ratatui::init();
    // Run the app in a loop until the user exits the app.
    let app_result = App::default().run(&mut terminal);
    // Restore the terminal back to its original state
    if let Err(err) = ratatui::try_restore() {
        eprintln!(
            "failed to restore terminal. Run `reset` or restart your terminal to recover: {err}"
        );
    }
    app_result
}
脚注
  1. https://blog.orhun.dev/ratatui-0-23-0/ ↩︎

  2. https://www.youtube.com/watch?v=F04kQMKwrwQ ↩︎

  3. 色付き文字やカーソル判定などあらゆるターミナルに関する操作を制御できる低レベルなクレート: https://docs.rs/crossterm/latest/crossterm/ ↩︎

  4. 具体的にはこの部分: https://ratatui.rs/tutorials/counter-app/error-handling/#setup-hooks ↩︎

Discussion