Skip to content

Basic Counter App

このページのコードの完全なコピーは、WebサイトのGitHubリポジトリで入手できます。

https://github.com/ratatui/ratatui-website/tree/main/code/tutorials/counter-app-basic

新しいプロジェクトを作成する

新しいRustプロジェクトを作成し、エディタで開きます

create counter app project
cargo new ratatui-counter-app
cd ratatui-counter-app
$EDITOR .

Ratatui および Crossterm クレートを追加します (Crossterm を使用する理由の詳細については、[バックエンド] を参照してください)。

add dependencies
cargo add ratatui crossterm

Cargo.toml の依存関係セクションには次の内容が含まれるようになります。

Cargo.toml
[dependencies]
ratatui = "0.29.0"
crossterm = "0.28.1"

アプリケーションのセットアップ

主な Import

main.rs で、Ratatui と crossterm に必要なインポートを追加します。これらは、このチュートリアルの後半で使用します。チュートリアルでは、コードを簡素化するためにワイルドカード インポートを使用するのが一般的ですが、明示的なインポートの方が好みのスタイルであれば、使用してもかまいません。

src/main.rs
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::Stylize,
symbols::border,
text::{Line, Text},
widgets::{Block, Paragraph, Widget},
DefaultTerminal, Frame,
};

メイン関数

ほとんどのRatatuiアプリで見られる一般的なパターンは、次のことです。

  1. ターミナルを初期化する
  2. ユーザーがアプリを終了するまでアプリケーションをループで実行する
  3. ターミナルを元の状態に戻す

main 関数は、ratatui::init メソッドと ratatui::restore メソッドを呼び出してターミナルを設定し、その後、App (後で定義) を作成して実行します。ターミナルが復元されるまで、App::run() の結果の戻り値の伝播を延期して、アプリケーションの終了後に Error 結果がユーザーに表示されるようにします。

メイン関数に記入してください:

src/main.rs
fn main() -> io::Result<()> {
let mut terminal = ratatui::init();
let app_result = App::default().run(&mut terminal);
ratatui::restore();
app_result
}

アプリケーション状態

カウンターアプリは、アプリケーションが終了することを示すために、いくつかの状態、カウンター、フラグを保存する必要があります。カウンターは8ビットの符号のないINTになり、出口フラグは簡単なブールになります。複数のメイン状態またはモードを持つアプリケーションは、代わりにこのフラグを表す列挙を使用する場合があります。

App structを作成して、アプリケーションの状態を表します。

src/main.rs
#[derive(Debug, Default)]
pub struct App {
counter: u8,
exit: bool,
}

App::default() を呼び出すと、 counter が0に設定された App および exitfalse に設定された App が作成されます。

アプリケーションメインループ

ほとんどのアプリには、ユーザーが終了を選択するまで実行されるメイン ループがあります。ループの各反復では、Terminal::draw() を呼び出して 1 つのフレームを描画し、アプリの状態を更新します。

アプリケーションのメイン ループとして機能する新しい run メソッドを使用して、Appimpl ブロックを作成します。

src/main.rs
impl App {
/// runs the application's main loop until the user quits
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
while !self.exit {
terminal.draw(|frame| self.draw(frame))?;
self.handle_events()?;
}
Ok(())
}
fn draw(&self, frame: &mut Frame) {
todo!()
}
fn handle_events(&mut self) -> io::Result<()> {
todo!()
}
}

アプリケーションの表示

フレームをレンダリングする

UI をレンダリングするために、アプリケーションは Frame を受け入れるクロージャを使用して Terminal::draw() を呼び出します。 Frame の最も重要なメソッドは render_widget() です。これは、ParagraphList などの Widget トレイト を実装する任意の型をレンダリングします。レンダリングに関連するコードが 1 か所に整理されるように、App 構造体に Widget trait を実装します。 これにより、Terminal::draw に渡されるクロージャ内のアプリを使用して Frame::render_widget() を呼び出すことができます。

まず、新しい impl Widget for &App ブロックを追加します。レンダリング関数は状態を変更せず、呼び出し後にアプリを使用できるようにするために、これを App 型への参照に実装します。レンダリング関数は、タイトル、下部の説明テキスト、およびいくつかの境界線を含むブロックを作成します。ブロック内にアプリケーションの状態 (App のカウンター フィールドの値) を含む Paragraph ウィジェットをレンダリングします。ブロックと段落はウィジェットのサイズ全体を占めます。

src/main.rs
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);
}
}

次に、アプリをウィジェットとしてレンダリングします。

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

UI出力のテスト

render が呼び出されたときに、ratatuiがウィジェットをどのように表示するかをテストするために、テストでバッファーにアプリをレンダリングできます。

次の tests モジュールを main.rs に追加します:

src/main.rs
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Style;
#[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);
}
}

このテストを実行するには、ターミナルで以下を実行します。

run tests
cargo test

次のように表示されます。

test output
running 1 test
test tests::render ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

インタラクティブ性

アプリケーションは、標準入力を介してユーザーから来るイベントを受け入れる必要があります。このアプリケーションが心配する必要がある唯一のイベントは、重要なイベントです。他の利用可能なイベントの詳細については、Crossterm Events Module docsを参照してください。これらには、窓のサイズとフォーカス、貼り付け、マウスのイベントが含まれます。

より高度なアプリケーションでは、イベントはシステム、ネットワーク上、またはアプリケーションの他の部分からもたらされる可能性があります。

イベントを処理する

先ほど定義した handle_events メソッドは、アプリが crossterm から提供されるイベントを待機して処理する場所です。

前に定義した handle_events メソッドを更新します。

src/main.rs
impl App {
// -- snip --
/// updates the application's state based on user input
fn handle_events(&mut self) -> io::Result<()> {
match event::read()? {
// it's important to check that the event is a key press event as
// crossterm also emits key release and repeat events on Windows.
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
self.handle_key_event(key_event)
}
_ => {}
};
Ok(())
}
}

キーボードイベントを処理する

カウンターアプリは、押されたキーに基づいて App structのフィールドの状態を更新します。キーボードイベントには、このアプリに関心のある2つのフィールドがあります。

  • kind: これが KeyEventKind::Press と等しいことを確認することが重要です。そうでないと、アプリケーションで重複したイベント (キーダウン、キーリピート、キーアップ) が発生する可能性があります。
  • code: 押された特定のキーを表す KeyCode

キーイベントを処理するには、 handle_key_event メソッドを App に追加します。

src/main.rs
impl App {
// -- snip --
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Char('q') => self.exit(),
KeyCode::Left => self.decrement_counter(),
KeyCode::Right => self.increment_counter(),
_ => {}
}
}
}

次に、アプリケーションの状態の更新を処理するためのメソッドをいくつか追加します。通常、これらのメソッドは、match ステートメントだけでなくアプリ上で定義することをお勧めします。これにより、イベントとは別にアプリケーションの動作を単体テストする簡単な方法が提供されます。

src/main.rs
impl App {
// -- snip --
fn exit(&mut self) {
self.exit = true;
}
fn increment_counter(&mut self) {
self.counter += 1;
}
fn decrement_counter(&mut self) {
self.counter -= 1;
}
}

キーボードイベントのテスト

このようにキーボード イベント処理を別の関数に分割すると、ターミナルをエミュレートしなくてもアプリケーションを簡単にテストできます。キーボード イベントを渡すテストを記述し、アプリケーションへの影響をテストできます。

tests モジュールに handle_key_event のテストを追加します。

src/main.rs
#[cfg(test)]
mod tests {
// -- snip --
#[test]
fn handle_key_event() -> io::Result<()> {
let mut app = App::default();
app.handle_key_event(KeyCode::Right.into());
assert_eq!(app.counter, 1);
app.handle_key_event(KeyCode::Left.into());
assert_eq!(app.counter, 0);
let mut app = App::default();
app.handle_key_event(KeyCode::Char('q').into());
assert!(app.exit);
Ok(())
}
}

テストを実行します。

run tests
cargo test

次のように表示されます。

test output
running 2 tests
test tests::handle_key_event ... ok
test tests::render ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

完成したアプリ

これを完全に言えば、これで次のファイルが必要です。

main.rs (click to expand)
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::Stylize,
symbols::border,
text::{Line, Text},
widgets::{Block, Paragraph, Widget},
DefaultTerminal, Frame,
};
fn main() -> io::Result<()> {
let mut terminal = ratatui::init();
let app_result = App::default().run(&mut terminal);
ratatui::restore();
app_result
}
#[derive(Debug, Default)]
pub struct App {
counter: u8,
exit: bool,
}
impl App {
/// runs the application's main loop until the user quits
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
while !self.exit {
terminal.draw(|frame| self.draw(frame))?;
self.handle_events()?;
}
Ok(())
}
fn draw(&self, frame: &mut Frame) {
frame.render_widget(self, frame.area());
}
/// updates the application's state based on user input
fn handle_events(&mut self) -> io::Result<()> {
match event::read()? {
// it's important to check that the event is a key press event as
// crossterm also emits key release and repeat events on Windows.
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
self.handle_key_event(key_event)
}
_ => {}
};
Ok(())
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Char('q') => self.exit(),
KeyCode::Left => self.decrement_counter(),
KeyCode::Right => self.increment_counter(),
_ => {}
}
}
fn exit(&mut self) {
self.exit = true;
}
fn increment_counter(&mut self) {
self.counter += 1;
}
fn decrement_counter(&mut self) {
self.counter -= 1;
}
}
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);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Style;
#[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);
}
#[test]
fn handle_key_event() -> io::Result<()> {
let mut app = App::default();
app.handle_key_event(KeyCode::Right.into());
assert_eq!(app.counter, 1);
app.handle_key_event(KeyCode::Left.into());
assert_eq!(app.counter, 0);
let mut app = App::default();
app.handle_key_event(KeyCode::Char('q').into());
assert!(app.exit);
Ok(())
}
}

アプリを実行する

すべてのファイルを保存し、上記の[インポート]がまだファイルの上部にあることを確認してください (一部のエディタは未使用のインポートを自動的に削除します) 。

ここでアプリを実行します:

run the app
cargo run

次のUIが表示されます。

basic-app demo

矢印キーを押して、カウンターと対話します。q を押して終了します。

カウンターが0の場合、 を押すとどうなりますか?

basic-app demo

Mac / Linuxコンソールでは、 reset を実行してコンソールを修正できます。Windowsコンソールでは、問題をクリアするためにコンソールを再起動する必要がある場合があります。[エラー処理]に関するこのチュートリアルの次のセクションでこれを適切に処理します。

結論

このシンプルなカウンター アプリケーションで使用されている構造とコンポーネントを理解することで、ratatui を使用してより複雑なターミナル ベースのインターフェイスを作成する準備が整います。