Skip to content

Basic Counter App

A full copy of the code for this page is available in the github repository for the website at:

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

Create a new project

Create a new rust project and open it in your editor

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

Add the Ratatui and Crossterm crates (See backends for more info on why we use Crossterm).

add dependencies
cargo add ratatui crossterm

The Cargo.toml will now have the following in the dependencies section:

Cargo.toml
[dependencies]
crossterm = "0.27.0"
ratatui = "0.26.2"

Application Setup

Main Imports

In main.rs, add the necessary imports for Ratatui and crossterm. These will be used later in this tutorial. In the tutorials, we generally use wildcard imports to simplify the code, but you’re welcome to use explicit imports if that is your preferred style.

src/main.rs
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
prelude::*,
symbols::border,
widgets::{block::*, *},
};

Main Function

A common pattern found in most Ratatui apps is that they:

  1. Initialize the terminal
  2. Run the application in a loop until the user exits the app
  3. Restore the terminal back to its original state

The main function sets up the terminal by calling methods in the tui module (defined next), and then creates and runs the App (defined later). It defers evaluating the result of calling App::run() until after the terminal is restored to ensure that any Error results will be displayed to the user after the application exits.

Fill out the main function:

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

TUI module

The counter app will be displayed on the alternate screen. This is a secondary buffer that allows the application to avoid messing up the user’s current shell. The app also enables raw mode so that the app can process keys immediately without having to wait for a newline and so that the keys are not echoed to the user’s screen when pressed.

Let’s implement this by creating a new module named tui to encapsulate this functionality into an init and a restore functions.

Add a module to main.rs after the imports section:

src/main.rs
mod tui;

Create a new file named src/tui.rs for the module. Add the imports, and two new functions, init and restore:

src/tui.rs
use std::io::{self, stdout, Stdout};
use crossterm::{execute, terminal::*};
use ratatui::prelude::*;
/// A type alias for the terminal type used in this application
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
/// Initialize the terminal
pub fn init() -> io::Result<Tui> {
execute!(stdout(), EnterAlternateScreen)?;
enable_raw_mode()?;
Terminal::new(CrosstermBackend::new(stdout()))
}
/// Restore the terminal to its original state
pub fn restore() -> io::Result<()> {
execute!(stdout(), LeaveAlternateScreen)?;
disable_raw_mode()?;
Ok(())
}

There is a PR to simplify this boilerplate code, but for now it’s most convenient to write a small helper module to handle this.

Application State

The counter app needs to store a small amount of state, a counter and a flag to indicate that the application should exit. The counter will be an 8-bit unsigned int, and the exit flag can be a simple bool. Applications that have more than one main state or mode might instead use an enum to represent this flag.

Create an App struct to represent your application’s state:

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

Calling App::default() will create an App initialized with counter set to 0, and exit set to false.

Application Main loop

Most apps have a main loop that runs until the user chooses to exit. Each iteration of the loop draws a single frame by calling Terminal::draw() and then updates the state of the app.

Create an impl block for the App with a new run method that will act as the application’s main loop:

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

Displaying the application

Render a Frame

To render the UI, an application callsTerminal::draw() with a closure that accepts a Frame. The most important method on Frame is render_widget() which renders any type that implements the Widget trait such as Paragraph, List etc., We will implement the Widget for the App struct so that the code related to rendering is organized in a single place. This allows us to call Frame::render_widget() with the app in the closure passed to Terminal::draw.

First, add a new impl Widget for &App block. We implement this on a reference to the App type, as the render function will not mutate any state, and we want to be able to use the app after the call to draw. The render function will create a block with a title, instruction text on the bottom, and some borders. Render a Paragraph widget with the application’s state (the value of the Apps counter field) inside the block. The block and paragraph will take up the entire size of the widget:

src/main.rs
impl Widget for &App {
fn render(self, area: Rect, buf: &mut Buffer) {
let title = Title::from(" Counter App Tutorial ".bold());
let instructions = Title::from(Line::from(vec![
" Decrement ".into(),
"<Left>".blue().bold(),
" Increment ".into(),
"<Right>".blue().bold(),
" Quit ".into(),
"<Q> ".blue().bold(),
]));
let block = Block::default()
.title(title.alignment(Alignment::Center))
.title(
instructions
.alignment(Alignment::Center)
.position(Position::Bottom),
)
.borders(Borders::ALL)
.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);
}
}

Next, render the app as a widget:

src/main.rs
impl App {
// -- snip --
fn render_frame(&self, frame: &mut Frame) {
frame.render_widget(self, frame.size());
}
}

Testing the UI Output

To test how how Ratatui will display the widget when render is called, you can render the app to a buffer in a test.

Add the following tests module to main.rs:

src/main.rs
#[cfg(test)]
mod tests {
use super::*;
#[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);
// note ratatui also has an assert_buffer_eq! macro that can be used to
// compare buffers and display the differences in a more readable way
assert_eq!(buf, expected);
}
}

To run this test run the following in your terminal:

run tests
cargo test

You should see:

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

Interactivity

The application needs to accept events that come from the user via the standard input. The only events this application needs to worry about are key events. For information on other available events, see the Crossterm events module docs. These include window resize and focus, paste and mouse events.

In more advanced applications, events might come from the system, over the network, or from other parts of the application.

Handle Events

The handle_events method that you defined earlier is where the app will wait for and handle any events that are provided to it from crossterm.

Update the handle_events method that you defined earlier:

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(())
}
}

Handle Keyboard Events

Your counter app will update the state of the App struct’s fields based on the key that was pressed. The keyboard event has two fields of interest to this app:

  • kind: It’s important to check that this equals KeyEventKind::Press as otherwise your application may see duplicate events (for key down, key repeat, and key up).
  • code: the KeyCode representing which specific key that was pressed.

Add a handle_key_event method to App, to handle the key events.

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(),
_ => {}
}
}
}

Next, add some methods to handle updating the application’s state. It’s usually a good idea to define these on the app rather than just in the match statement as it gives you an easy way to unit test the application’s behavior separately to the events.

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;
}
}

Testing Keyboard Events

Splitting the keyboard event handling out to a separate function like this makes it easy to test the application without having to emulate the terminal. You can write tests that pass in keyboard events and test the effect on the application.

Add tests for handle_key_event in the tests module.

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_eq!(app.exit, true);
Ok(())
}
}

Run the tests.

run tests
cargo test

You should see:

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

The Finished App

Putting this altogether, you should now have the following files:

main.rs (click to expand)
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
prelude::*,
symbols::border,
widgets::{block::*, *},
};
mod tui;
fn main() -> io::Result<()> {
let mut terminal = tui::init()?;
let app_result = App::default().run(&mut terminal);
tui::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 tui::Tui) -> io::Result<()> {
while !self.exit {
terminal.draw(|frame| self.render_frame(frame))?;
self.handle_events()?;
}
Ok(())
}
fn render_frame(&self, frame: &mut Frame) {
frame.render_widget(self, frame.size());
}
/// 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 = Title::from(" Counter App Tutorial ".bold());
let instructions = Title::from(Line::from(vec![
" Decrement ".into(),
"<Left>".blue().bold(),
" Increment ".into(),
"<Right>".blue().bold(),
" Quit ".into(),
"<Q> ".blue().bold(),
]));
let block = Block::default()
.title(title.alignment(Alignment::Center))
.title(
instructions
.alignment(Alignment::Center)
.position(Position::Bottom),
)
.borders(Borders::ALL)
.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::*;
#[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);
// note ratatui also has an assert_buffer_eq! macro that can be used to
// compare buffers and display the differences in a more readable way
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_eq!(app.exit, true);
Ok(())
}
}
tui.rs (click to expand)
use std::io::{self, stdout, Stdout};
use crossterm::{execute, terminal::*};
use ratatui::prelude::*;
/// A type alias for the terminal type used in this application
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
/// Initialize the terminal
pub fn init() -> io::Result<Tui> {
execute!(stdout(), EnterAlternateScreen)?;
enable_raw_mode()?;
Terminal::new(CrosstermBackend::new(stdout()))
}
/// Restore the terminal to its original state
pub fn restore() -> io::Result<()> {
execute!(stdout(), LeaveAlternateScreen)?;
disable_raw_mode()?;
Ok(())
}

Running the app

Make sure you save all the files and that the imports listed above are still at the top of the file (some editors remove unused imports automatically).

Now run the app:

run the app
cargo run

You will see the following UI:

basic-app demo

Press the Left and Right arrow keys to interact with the counter. Press Q to quit.

Note what happens when you press Left when the counter is 0.

basic-app demo

On a Mac / Linux console you can run reset to fix the console. On a Windows console, you may need to restart the console to clear the problem. We will properly handle this in the next section of this tutorial on Error Handling.

Conclusion

By understanding the structure and components used in this simple counter application, you are set up to explore crafting more intricate terminal-based interfaces using ratatui.