Skip to content

Counter App Error Handling

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-error-handling.

In the previous section, you created a basic counter app that responds to the user pressing a the Left and Right arrow keys to control the value of a counter. This tutorial will start with that code and add error and panic handling.

A quick reminder of where we left off in the basic app:

Cargo.toml (click to expand)
# -- snip --
[dependencies]
crossterm = "0.27.0"
ratatui = "0.26.2"
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(())
}

The problem

The app you built in the previous section has an intentional error in that causes the app to panic when the user presses the Left arrow key when the Counter is already at 0. When this happens, the main function does not have a chance to restore the terminal state before it exits.

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

The application’s default panic handler runs by displays the details messed up. This is because raw mode stops the terminal from interpreting newlines in the usual way. The shell prompt is also rendered at the wrong place.

Basic App Error

To recover from this, on a macOS or Linux console, run the reset command. On a Windows console you may need to restart the console.

Setup Hooks

There are two ways that a rust application can fail. The rust book chapter on error handling explains this in better detail.

Rust groups errors into two major categories: recoverable and unrecoverable errors. For a recoverable error, such as a file not found error, we most likely just want to report the problem to the user and retry the operation. Unrecoverable errors are always symptoms of bugs, like trying to access a location beyond the end of an array, and so we want to immediately stop the program. — https://doc.rust-lang.org/book/ch09-00-error-handling.html

One approach that makes it easy to show unhandled errors is to use the color_eyre crate to augment the error reporting hooks. In a ratatui application that’s running on the alternate screen in raw mode, it’s important to restore the terminal before displaying these errors to the user.

Add the color-eyre crate

add color-eyre
cargo add color-eyre

Add a new module named errors to main.rs.

main.rs
mod errors;
mod tui;

Create a new function named install_hooks in errors.rs

errors.rs
use std::panic;
use color_eyre::{config::HookBuilder, eyre};
use crate::tui;
/// This replaces the standard color_eyre panic and error hooks with hooks that
/// restore the terminal before printing the panic or error.
pub fn install_hooks() -> color_eyre::Result<()> {
let (panic_hook, eyre_hook) = HookBuilder::default().into_hooks();
// convert from a color_eyre PanicHook to a standard panic hook
let panic_hook = panic_hook.into_panic_hook();
panic::set_hook(Box::new(move |panic_info| {
tui::restore().unwrap();
panic_hook(panic_info);
}));
// convert from a color_eyre EyreHook to a eyre ErrorHook
let eyre_hook = eyre_hook.into_eyre_hook();
eyre::set_hook(Box::new(
move |error: &(dyn std::error::Error + 'static)| {
tui::restore().unwrap();
eyre_hook(error)
},
))?;
Ok(())
}

This function will replace the application’s existing panic hook with one that first restores the terminal state back to normal and then runs the existing hook. It does the same for the color_eyre hook, which handles errors (i.e. any Result::Errs that are not otherwise handled)

Update the main function’s return value to color_eyre::Result<()> and call the the new install_hooks function.

main.rs
use color_eyre::{
eyre::{bail, WrapErr},
Result,
};
fn main() -> Result<()> {
errors::install_hooks()?;
let mut terminal = tui::init()?;
App::default().run(&mut terminal)?;
tui::restore()?;
Ok(())
}

Previously the main function needed to store the result of calling run() and evaluate it after restoring the terminal. The eyre hook now automatically handles this.

Using color_eyre

Color eyre works by adding extra information to Results. You can add context to the errors by calling wrap_err (defined on the color_eyre::eyre::WrapErr trait).

Update the `App::run function to add some information about the update function failing and change the return value.

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

Creating a recoverable error

The tutorial needs a synthetic error to show how we can handle recoverable errors. Change handle_key_event to return a color_eyre::Result and make sure the calls to increment and decrement calls have the ? operator to propagate the error to the caller.

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

Let’s add an error that occurs when the counter is above 2. Also change both methods’ return types. Add the new error to the increment_counter method. You can use the bail! macro for this:

main.rs
impl App {
fn decrement_counter(&mut self) -> Result<()> {
self.counter -= 1;
Ok(())
}
fn increment_counter(&mut self) -> Result<()> {
self.counter += 1;
if self.counter > 2 {
bail!("counter overflow");
}
Ok(())
}
}

In the handle_events method, add some extra information about which key caused the failure and update the return value.

main.rs
impl App {
/// updates the application's state based on user input
fn handle_events(&mut self) -> 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).wrap_err_with(|| {
format!("handling key event failed:\n{key_event:#?}")
})
}
_ => Ok(()),
}
}
}

Update the tests for this method to unwrap the calls to handle_key_events. This will cause the test to fail if an error is returned.

main.rs
mod tests {
#[test]
fn handle_key_event() {
let mut app = App::default();
app.handle_key_event(KeyCode::Right.into()).unwrap();
assert_eq!(app.counter, 1);
app.handle_key_event(KeyCode::Left.into()).unwrap();
assert_eq!(app.counter, 0);
let mut app = App::default();
app.handle_key_event(KeyCode::Char('q').into()).unwrap();
assert_eq!(app.exit, true);
}
}

Add tests for the panic and overflow conditions

main.rs
mod tests {
#[test]
#[should_panic(expected = "attempt to subtract with overflow")]
fn handle_key_event_panic() {
let mut app = App::default();
let _ = app.handle_key_event(KeyCode::Left.into());
}
#[test]
fn handle_key_event_overflow() {
let mut app = App::default();
assert!(app.handle_key_event(KeyCode::Right.into()).is_ok());
assert!(app.handle_key_event(KeyCode::Right.into()).is_ok());
assert_eq!(
app.handle_key_event(KeyCode::Right.into())
.unwrap_err()
.to_string(),
"counter overflow"
);
}
}

Run the tests:

run tests
cargo test
running 4 tests
thread 'tests::handle_key_event_panic' panicked at code/counter-app-error-handling/src/main.rs:94:9:
attempt to subtract with overflow
test tests::handle_key_event ... okstack backtrace:
test tests::handle_key_event_overflow ... ok
test tests::render ... ok
20 collapsed lines
0: rust_begin_unwind
at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/std/src/panicking.rs:645:5
1: core::panicking::panic_fmt
at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/panicking.rs:72:14
2: core::panicking::panic
at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/panicking.rs:144:5
3: counter_app_error_handling::App::decrement_counter
at ./src/main.rs:94:9
4: counter_app_error_handling::App::handle_key_event
at ./src/main.rs:79:30
5: counter_app_error_handling::tests::handle_key_event_panic
at ./src/main.rs:200:17
6: counter_app_error_handling::tests::handle_key_event_panic::{{closure}}
at ./src/main.rs:198:32
7: core::ops::function::FnOnce::call_once
at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/ops/function.rs:250:5
8: core::ops::function::FnOnce::call_once
at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
test tests::handle_key_event_panic - should panic ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

The Finished App

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

main.rs (click to expand)
use color_eyre::{
eyre::{bail, WrapErr},
Result,
};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
prelude::*,
symbols::border,
widgets::{
block::{Position, Title},
*,
},
};
mod errors;
mod tui;
fn main() -> Result<()> {
errors::install_hooks()?;
let mut terminal = tui::init()?;
App::default().run(&mut terminal)?;
tui::restore()?;
Ok(())
}
#[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) -> Result<()> {
while !self.exit {
terminal.draw(|frame| self.render_frame(frame))?;
self.handle_events().wrap_err("handle events failed")?;
}
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) -> 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).wrap_err_with(|| {
format!("handling key event failed:\n{key_event:#?}")
})
}
_ => Ok(()),
}
}
fn handle_key_event(&mut self, key_event: KeyEvent) -> Result<()> {
match key_event.code {
KeyCode::Char('q') => self.exit(),
KeyCode::Left => self.decrement_counter()?,
KeyCode::Right => self.increment_counter()?,
_ => {}
}
Ok(())
}
fn exit(&mut self) {
self.exit = true;
}
fn decrement_counter(&mut self) -> Result<()> {
self.counter -= 1;
Ok(())
}
fn increment_counter(&mut self) -> Result<()> {
self.counter += 1;
if self.counter > 2 {
bail!("counter overflow");
}
Ok(())
}
}
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() {
let mut app = App::default();
app.handle_key_event(KeyCode::Right.into()).unwrap();
assert_eq!(app.counter, 1);
app.handle_key_event(KeyCode::Left.into()).unwrap();
assert_eq!(app.counter, 0);
let mut app = App::default();
app.handle_key_event(KeyCode::Char('q').into()).unwrap();
assert_eq!(app.exit, true);
}
#[test]
#[should_panic(expected = "attempt to subtract with overflow")]
fn handle_key_event_panic() {
let mut app = App::default();
let _ = app.handle_key_event(KeyCode::Left.into());
}
#[test]
fn handle_key_event_overflow() {
let mut app = App::default();
assert!(app.handle_key_event(KeyCode::Right.into()).is_ok());
assert!(app.handle_key_event(KeyCode::Right.into()).is_ok());
assert_eq!(
app.handle_key_event(KeyCode::Right.into())
.unwrap_err()
.to_string(),
"counter overflow"
);
}
}
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(())
}
errors.rs (click to expand)
use std::panic;
use color_eyre::{config::HookBuilder, eyre};
use crate::tui;
/// This replaces the standard color_eyre panic and error hooks with hooks that
/// restore the terminal before printing the panic or error.
pub fn install_hooks() -> color_eyre::Result<()> {
let (panic_hook, eyre_hook) = HookBuilder::default().into_hooks();
// convert from a color_eyre PanicHook to a standard panic hook
let panic_hook = panic_hook.into_panic_hook();
panic::set_hook(Box::new(move |panic_info| {
tui::restore().unwrap();
panic_hook(panic_info);
}));
// convert from a color_eyre EyreHook to a eyre ErrorHook
let eyre_hook = eyre_hook.into_eyre_hook();
eyre::set_hook(Box::new(
move |error: &(dyn std::error::Error + 'static)| {
tui::restore().unwrap();
eyre_hook(error)
},
))?;
Ok(())
}

Handling Panics

Experiment to see what happens when the application panics. The application has an intentional bug where it uses u8 for the counter field, but doesn’t guard against decrementing this below 0. Run the app and press the Left arrow key.

panic demo

To get more information about where the error occurred, add RUST_BACKTRACE=full before the command.

panic-full demo

Handling Errors

Experiment to see what happens when the application returns an unhandled error as a result. The app will cause this to happen when the counter increases past 2. Run the app and press the Right arrow 3 times.

error demo

To get more information about where the error occurred, add RUST_BACKTRACE=full before the command.

error-full demo