Refactor

In this section, we will walk through the process of refactoring the application to set ourselves up better for bigger projects. Not all of these changes are ratatui specific, and are generally good coding practices to follow.

Organizing imports

The first thing you might consider doing is reorganizing imports with qualified names.

use crossterm::{
  event::{self, Event::Key, KeyCode::Char},
  execute,
  terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
  prelude::{CrosstermBackend, Terminal},
  widgets::Paragraph,
};

Typedefs and Type Aliases

By defining custom types and aliases, we can simplify our code and make it more expressive.

type Err = Box<dyn std::error::Error>;
type Result<T> = std::result::Result<T, Err>;
pub type Frame<'a> = ratatui::Frame<'a, CrosstermBackend<std::io::Stderr>>;

Tip

If you use the popular anyhow crate, then instead of these two lines:

type Err = Box<dyn std::error::Error>;
type Result<T> = std::result::Result<T, Err>;

you can simply import anyhow::Result:

use anyhow::Result;

You will need to run cargo add anyhow for this to work.

Frame is a shorthand type to represent the frame we draw to when we render our application.

App struct

By defining an App struct, we can encapsulate our application state and make it more structured.

struct App {
  counter: i64,
  should_quit: bool,
}
  • counter holds the current value of our counter.
  • should_quit is a flag that indicates whether the application should exit its main loop.

Breaking up main()

We can extract significant parts of the main() function into separate smaller functions, e.g. startup(), shutdown(), ui(), update(), run().

startup() is responsible for initializing the terminal.

fn startup() -> Result<()> {
  enable_raw_mode()?;
  execute!(std::io::stderr(), EnterAlternateScreen)?;
  Ok(())
}

shutdown() cleans up the terminal.

fn shutdown() -> Result<()> {
  execute!(std::io::stderr(), LeaveAlternateScreen)?;
  disable_raw_mode()?;
  Ok(())
}

ui() handles rendering of our application state.

fn ui(f: &mut Frame<'_>, app: &App) {
  f.render_widget(Paragraph::new(format!("Counter: {}", app.counter)), f.size());
}

update() processes user input and updates our application state.

fn update(app: &mut App) -> Result<()> {
  if event::poll(std::time::Duration::from_millis(250))? {
    if let Key(key) = event::read()? {
      match key.code {
        Char('j') => app.counter += 1,
        Char('k') => app.counter -= 1,
        Char('q') => app.should_quit = true,
        _ => (),
      }
    }
  }
  Ok(())
}

You’ll notice that in the update() function we make use of pattern matching for handling user input. This is a powerful feature in rust; and enhances readability and provides a clear pattern for how each input is processed.

run() contains our main application loop.

fn run() -> Result<()> {
  // ratatui terminal
  let mut t = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;

  // application state
  let mut app = App { counter: 0, should_quit: false };

  loop {
    // application render
    t.draw(|f| {
      ui(f, &app);
    })?;

    // application update
    update(&mut app)?;

    // application exit
    if app.should_quit {
      break;
    }
  }

  Ok(())
}

Each function now has a specific task, making our main application logic more organized and easier to follow.

fn main() -> Result<()> {
  startup()?;
  let status = run();
  shutdown()?;
  status?;
  Ok(())
}

Note

You may be wondering if we could have written the main function like so:

fn main() -> Result<()> {
  startup()?;
  run()?;
  shutdown()?;
  Ok(())
}

This works fine during the happy path of a program.

However, if your run() function returns an error, the program will not call shutdown(). And this can leave your terminal in a messed up state for your users.

Instead, we should ensure that shutdown() is always called before the program exits.

fn main() -> Result<()> {
  startup()?;
  let result = run();
  shutdown()?;
  result?;
  Ok(())
}

Here, we can get the result of run(), and call shutdown() first and then unwrap() on the result. This will be a much better experience for users.

We will discuss in future sections how to handle the situation when your code unexpectedly panics.

Conclusion

By making our code more organized, modular, and readable, we not only make it easier for others to understand and work with but also set the stage for future enhancements and extensions.

Here’s the full code for reference:

use anyhow::Result;
use crossterm::{
  event::{self, Event::Key, KeyCode::Char},
  execute,
  terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
  prelude::{CrosstermBackend, Terminal},
  widgets::Paragraph,
};

pub type Frame<'a> = ratatui::Frame<'a, CrosstermBackend<std::io::Stderr>>;

fn startup() -> Result<()> {
  enable_raw_mode()?;
  execute!(std::io::stderr(), EnterAlternateScreen)?;
  Ok(())
}

fn shutdown() -> Result<()> {
  execute!(std::io::stderr(), LeaveAlternateScreen)?;
  disable_raw_mode()?;
  Ok(())
}

// App state
struct App {
  counter: i64,
  should_quit: bool,
}

// App ui render function
fn ui(f: &mut Frame<'_>, app: &App) {
  f.render_widget(Paragraph::new(format!("Counter: {}", app.counter)), f.size());
}

// App update function
fn update(app: &mut App) -> Result<()> {
  if event::poll(std::time::Duration::from_millis(250))? {
    if let Key(key) = event::read()? {
      match key.code {
        Char('j') => app.counter += 1,
        Char('k') => app.counter -= 1,
        Char('q') => app.should_quit = true,
        _ => (),
      }
    }
  }
  Ok(())
}

fn run() -> Result<()> {
  // ratatui terminal
  let mut t = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;

  // application state
  let mut app = App { counter: 0, should_quit: false };

  loop {
    // application update
    update(&mut app)?;

    // application render
    t.draw(|f| {
      ui(f, &app);
    })?;

    // application exit
    if app.should_quit {
      break;
    }
  }

  Ok(())
}

fn main() -> Result<()> {
  // setup terminal
  startup()?;

  let result = run();

  // teardown terminal before unwrapping Result of app run
  shutdown()?;

  result?;

  Ok(())
}

Here’s a flow chart representation of the various steps in the program:

graph TD
    MainRun[Main: Run];
    CheckEvent[Main: Poll KeyPress];
    UpdateApp[Main: Update App];
    ShouldQuit[Main: Check should_quit?];
    BreakLoop[Main: Break Loop];
    MainStart[Main: Start];
    MainEnd[Main: End];
    MainStart --> MainRun;
    MainRun --> CheckEvent;
    CheckEvent -->|No KeyPress| Draw;
    CheckEvent --> |KeyPress Received| UpdateApp;
    Draw --> ShouldQuit;
    UpdateApp --> Draw;
    ShouldQuit -->|Yes| BreakLoop;
    BreakLoop --> MainEnd;
    ShouldQuit -->|No| CheckEvent;

Question

What do you think happens if you modify the example above to change the polling to 0 milliseconds?

What would happen if you change the example to poll every 10 seconds?

Experiment with different “tick rates” and see how that affects the user experience. Also, monitor your CPU usage when you do this experiment.