Main.rs
The main file in many ratatui applications is simply a place to store the startup loop, and
occasionally event handling. See more ways to handle events in
Event Handling
In this application, we will be using our main function to run the startup steps, and start the
main loop. We will also put our main loop logic and event handling in this file.
Main
In our main function, we will set up the terminal, create an application state and run our application, and finally reset the terminal to the state we found it in.
Application pre-run steps
Because a ratatui application takes the whole screen, and captures all of the keyboard input, we
need some boilerplate at the beginning of our main function.
use crossterm::event::EnableMouseCapture;use crossterm::execute;use crossterm::terminal::{enable_raw_mode, EnterAlternateScreen};use std::io;fn main() -> Result<(), Box<dyn Error>> { // setup terminal enable_raw_mode()?; let mut stderr = io::stderr(); // This is a special case. Normally using stdout is fine execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?; // --snip--You might notice that we are using stderr for our output. This is because we want to allow the
user to pipe their completed json to other programs like ratatui-tutorial > output.json. To do
this, we are utilizing the fact that stderr is piped differently than stdout, and rendering out
project in stderr, and printout our completed json in stdout.
For more information, please read the crossterm documentation
State creation, and loop starting
Now that we have prepared the terminal for our application to run, it is time to actually run it.
First, we need to create an instance of our ApplicationState or app, to hold all of the
program’s state, and then we will call our function which handles the event and draw loop.
// --snip-- let backend = CrosstermBackend::new(stderr); let mut terminal = Terminal::new(backend)?;
// create app and run it let mut app = App::new(); let res = run_app(&mut terminal, &mut app); // --snip--Application post-run steps
Since our ratatui application has changed the state of the user’s terminal with our
pre-run boilerplate, we need to undo what we have done, and put the
terminal back to the way we found it.
Most of these functions will simply be the inverse of what we have done above.
use crossterm::event::DisableMouseCapture;use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen}; // --snip-- // restore terminal disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; // --snip--When an application exits without running this closing boilerplate, the terminal will act very strange, and the user will usually have to end the terminal session and start a new one. Thus it is important that we handle our error in such a way that we can call this last piece.
// --snip-- if let Ok(do_print) = res { if do_print { app.print_json()?; } } else if let Err(err) = res { println!("{err:?}"); }
Ok(())}The if statement at the end of boilerplate checks if the run_app function errored, or if it
returned an Ok state. If it returned an Ok state, we need to check if we should print the json.
If we don’t call our print function before we call execute!(LeaveAlternateScreen), our prints will
be rendered on an old screen and lost when we leave the alternate screen. (For more information on
how this works, read the
Crossterm documentation)
So, altogether, our finished function should looks like this:
fn main() -> Result<(), Box<dyn Error>> { // setup terminal enable_raw_mode()?; let mut stderr = io::stderr(); // This is a special case. Normally using stdout is fine execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stderr); let mut terminal = Terminal::new(backend)?;
// create app and run it let mut app = App::new(); let res = run_app(&mut terminal, &mut app);
// restore terminal disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?;
if let Ok(do_print) = res { if do_print { app.print_json()?; } } else if let Err(err) = res { println!("{err:?}"); }
Ok(())}run_app
In this function, we will start to do the actual logic.
Method signature
Let’s start with the method signature:
fn run_app<B: Backend>( terminal: &mut Terminal<B>, app: &mut App,) -> io::Result<bool> { // --snip--You’ll notice that we make this function generic across the ratatui::backend::Backend. In previous
sections we hardcoded the CrosstermBackend. This trait approach allows us to make our code backend
agnostic.
This method accepts an object of type Terminal which implements the ratatui::backend::Backend
trait. This trait includes the three (four counting the TestBackend) officially supported backends
included in ratatui. It allows for 3rd party backends to be implemented.
run_app also requires a mutable borrow to an application state object, as defined in this project.
Finally, the run_app returns an io::Result<bool> that indicates if there was an io error with
the Err state, and an Ok(true) or Ok(false) that indicates if the program should print out the
finished json.
UI Loop
Because ratatui requires us to implement our own event/ui loop, we will simply use the following
code to update our main loop.
// --snip-- loop { terminal.draw(|f| ui(f, app))?; // --snip--Let’s unpack that draw call really quick.
terminalis theTerminal<Backend>that we take as an argument,drawis theratatuicommand to draw aFrameto the terminal1.|f| ui(f, &app)tellsdrawthat we want to takef: <Frame>and pass it to our functionui, anduiwill draw to thatFrame.
Notice that we also pass an immutable borrow of our application state to the ui function. This
will be important later.
Event handling
Now that we have started our app , and have set up the UI rendering, we will implement the event handling.
Polling
Because we are using crossterm, we can simply poll for keyboard events with
if let Event::Key(key) = event::read()? { dbg!(key.code)}and then match the results.
Alternatively, we can set up a thread to run in the background to poll and send Events (as we did
in the “counter” tutorial). Let’s keep things simple here for the sake of illustration.
Note that the process for polling events will vary on the backend you are utilizing, and you will need to refer to the documentation of that backend for more information.
Main Screen
We will start with the keybinds and event handling for the CurrentScreen::Main.
// --snip-- if let Event::Key(key) = event::read()? { if key.kind == event::KeyEventKind::Release { // Skip events that are not KeyEventKind::Press continue; } match app.current_screen { CurrentScreen::Main => match key.code { KeyCode::Char('e') => { app.current_screen = CurrentScreen::Editing; app.currently_editing = Some(CurrentlyEditing::Key); } KeyCode::Char('q') => { app.current_screen = CurrentScreen::Exiting; } _ => {} }, // --snip--After matching to the Main enum variant, we match the event. When the user is in the main screen,
there are only two keybinds, and the rest are ignored.
In this case, KeyCode::Char('e') changes the current screen to CurrentScreen::Editing and sets
the CurrentlyEditing to a Some and notes that the user should be editing the Key value field,
as opposed to the Value field.
KeyCode::Char('q') is straightforward, as it simply switches the application to the Exiting
screen, and allows the ui and future event handling runs to do the rest.
Exiting
The next handler we will prepare, will handle events while the application is on the
CurrentScreen::Exiting. The job of this screen is to ask if the user wants to exit without
outputting the json. It is simply a y/n question, so that is all we listen for. We also add an
alternate exit key with q. If the user chooses to output the json, we return an Ok(true) that
indicates that our main function should call app.print_json() to perform the serialization and
printing for us after resetting the terminal to normal
// --snip-- CurrentScreen::Exiting => match key.code { KeyCode::Char('y') => { return Ok(true); } KeyCode::Char('n') | KeyCode::Char('q') => { return Ok(false); } _ => {} }, // --snip--Editing
Our final handler will be a bit more involved, as we will be changing the state of internal variables.
We would like the Enter key to serve two purposes. When the user is editing the Key, we want the
enter key to switch the focus to editing the Value. However, if the Value is what is being
currently edited, Enter will save the key-value pair, and return to the Main screen.
// --snip-- CurrentScreen::Editing if key.kind == KeyEventKind::Press => { match key.code { KeyCode::Enter => { if let Some(editing) = &app.currently_editing { match editing { CurrentlyEditing::Key => { app.currently_editing = Some(CurrentlyEditing::Value); } CurrentlyEditing::Value => { app.save_key_value(); app.current_screen = CurrentScreen::Main; } } } } // --snip--When Backspace is pressed, we need to first determine if the user is editing a Key or a Value,
then pop() the endings of those strings accordingly.
// --snip-- KeyCode::Backspace => { if let Some(editing) = &app.currently_editing { match editing { CurrentlyEditing::Key => { app.key_input.pop(); } CurrentlyEditing::Value => { app.value_input.pop(); } } } } // --snip--When Escape is pressed, we want to quit editing.
// --snip-- KeyCode::Esc => { app.current_screen = CurrentScreen::Main; app.currently_editing = None; } // --snip--When Tab is pressed, we want the currently editing selection to switch.
// --snip-- KeyCode::Tab => { app.toggle_editing(); } // --snip--And finally, if the user types a valid character, we want to capture that, and add it to the string that is the final key or value.
// --snip-- KeyCode::Char(value) => { if let Some(editing) = &app.currently_editing { match editing { CurrentlyEditing::Key => { app.key_input.push(value); } CurrentlyEditing::Value => { app.value_input.push(value); } } } } // --snip--Altogether, the event loop should look like this:
// --snip-- if let Event::Key(key) = event::read()? { if key.kind == event::KeyEventKind::Release { // Skip events that are not KeyEventKind::Press continue; } match app.current_screen { CurrentScreen::Main => match key.code { KeyCode::Char('e') => { app.current_screen = CurrentScreen::Editing; app.currently_editing = Some(CurrentlyEditing::Key); } KeyCode::Char('q') => { app.current_screen = CurrentScreen::Exiting; } _ => {} }, CurrentScreen::Exiting => match key.code { KeyCode::Char('y') => { return Ok(true); } KeyCode::Char('n') | KeyCode::Char('q') => { return Ok(false); } _ => {} }, CurrentScreen::Editing if key.kind == KeyEventKind::Press => { match key.code { KeyCode::Enter => { if let Some(editing) = &app.currently_editing { match editing { CurrentlyEditing::Key => { app.currently_editing = Some(CurrentlyEditing::Value); } CurrentlyEditing::Value => { app.save_key_value(); app.current_screen = CurrentScreen::Main; } } } } KeyCode::Backspace => { if let Some(editing) = &app.currently_editing { match editing { CurrentlyEditing::Key => { app.key_input.pop(); } CurrentlyEditing::Value => { app.value_input.pop(); } } } } KeyCode::Esc => { app.current_screen = CurrentScreen::Main; app.currently_editing = None; } KeyCode::Tab => { app.toggle_editing(); } KeyCode::Char(value) => { if let Some(editing) = &app.currently_editing { match editing { CurrentlyEditing::Key => { app.key_input.push(value); } CurrentlyEditing::Value => { app.value_input.push(value); } } } } _ => {} } } _ => {} } } // --snip--Footnotes
-
Technically this is the command to the
Terminal<Backend>, but that only matters on theTestBackend. ↩