In this section of the tutorial, we are going to discuss the basic components of the Tui struct.
You’ll find most people setup and teardown of a terminal application using crossterm like so:
You can use termion or termwiz instead here, and you’ll have to change the implementation of
setup_terminal and teardown_terminal.
I personally like to use crossterm so that I can run the TUI on windows as well.
We can reorganize the setup and teardown functions into an enter() and exit() methods on a Tui
struct.
Feel free to modify this as you need for use with termion or wezterm.
The type alias to Frame is only to make the components folder easier to work with, and is not
strictly required.
Event
In it’s simplest form, most applications will have a main loop like this:
While we are in the “raw mode”, i.e. after we call t.enter(), any key presses in that terminal
window are sent to stdin. We have to read these key presses from stdin if we want to act on
them.
There’s a number of different ways to do that. crossterm has a event module that implements
features to read these key presses for us.
Let’s assume we were building a simple “counter” application, that incremented a counter when we
pressed j and decremented a counter when we pressed k.
This works perfectly fine, and a lot of small to medium size programs can get away with doing just
that.
However, this approach conflates the key input handling with app state updates, and does so in the
“draw” loop. The practical issue with this approach is we block the draw loop for 250 ms waiting for
a key press. This can have odd side effects, for example pressing an holding a key will result in
faster draws to the terminal.
In terms of architecture, the code could get complicated to reason about. For example, we may even
want key presses to mean different things depending on the state of the app (when you are focused
on an input field, you may want to enter the letter "j" into the text input field, but when
focused on a list of items, you may want to scroll down the list.)
We have to do a few different things set ourselves up, so let’s take things one step at a time.
First, instead of polling, we are going to introduce channels to get the key presses asynchronously
and send them over a channel. We will then receive on the channel in the main loop.
There are two ways to do this. We can either use OS threads or “green” threads, i.e. tasks, i.e.
rust’s async-await features + a future executor.
Here’s example code of reading key presses asynchronously using std::thread and tokio::task.
std::thread
tokio::task
diff
Tokio is an asynchronous runtime for the Rust programming language. It is one of the more popular
runtimes for asynchronous programming in rust. You can learn more about here
https://tokio.rs/tokio/tutorial. For the rest of the tutorial here, we are going to assume we want
to use tokio. I highly recommend you read the official tokio documentation.
If we use tokio, receiving a event requires .await. So our main loop now looks like this:
Additional improvements
We are going to modify our EventHandler to handle a AppTick event. We want the Event::AppTick
to be sent at regular intervals. We are also going to want to use a CancellationToken to stop the
tokio task on request.
tokio’s select! macro allows us to wait on multiple
async computations and returns when a single computation completes.
Here’s what the completed EventHandler code now looks like:
With this EventHandler implemented, we can use tokio to create a separate “task” that handles
any key asynchronously in our main loop.
I personally like to combine the EventHandler and the Tui struct into one struct. Here’s an
example of that Tui struct for your reference.
In the next section, we will introduce a Command pattern to bridge handling the effect of an
event.