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.
terminal
is theTerminal<Backend>
that we take as an argument,draw
is theratatui
command to draw aFrame
to the terminal1.|f| ui(f, &app)
tellsdraw
that we want to takef: <Frame>
and pass it to our functionui
, andui
will draw to thatFrame
.
Technically this is the command to the Terminal<Backend>
, but that only matters on the TestBackend
.
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 Event
s (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--