tui.rs
Terminal
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:
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
let mut stdout = io::stdout();
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture, HideCursor)?;
Terminal::new(CrosstermBackend::new(stdout))
}
fn teardown_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
let mut stdout = io::stdout();
crossterm::terminal::disable_raw_mode()?;
crossterm::execute!(stdout, LeaveAlternateScreen, DisableMouseCapture, ShowCursor)?;
Ok(())
}
fn main() -> Result<()> {
let mut terminal = setup_terminal()?;
run_app(&mut terminal)?;
teardown_terminal(&mut terminal)?;
Ok(())
}
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.
Terminals have two screen buffers for each window.
The default screen buffer is what you are dropped into when you start up a terminal.
The second screen buffer, called the alternate screen, is used for running interactive apps such as the vim
, less
etc.
Here’s a 8 minute talk on Terminal User Interfaces I gave at JuliaCon2020: https://www.youtube.com/watch?v=-TASx67pphw that might be worth watching for more information about how terminal user interfaces work.
We can reorganize the setup and teardown functions into an enter()
and exit()
methods on a Tui
struct.
use color_eyre::eyre::{anyhow, Context, Result};
use crossterm::{
cursor,
event::{DisableMouseCapture, EnableMouseCapture},
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::backend::CrosstermBackend as Backend;
use tokio::{
sync::{mpsc, Mutex},
task::JoinHandle,
};
pub type Frame<'a> = ratatui::Frame<'a, Backend<std::io::Stderr>>;
pub struct Tui {
pub terminal: ratatui::Terminal<Backend<std::io::Stderr>>,
}
impl Tui {
pub fn new() -> Result<Self> {
let terminal = ratatui::Terminal::new(Backend::new(std::io::stderr()))?;
Ok(Self { terminal })
}
pub fn enter(&self) -> Result<()> {
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(std::io::stderr(), EnterAlternateScreen, EnableMouseCapture, cursor::Hide)?;
Ok(())
}
pub fn exit(&self) -> Result<()> {
crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, DisableMouseCapture, cursor::Show)?;
crossterm::terminal::disable_raw_mode()?;
Ok(())
}
pub fn suspend(&self) -> Result<()> {
self.exit()?;
#[cfg(not(windows))]
signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?;
Ok(())
}
pub fn resume(&self) -> Result<()> {
self.enter()?;
Ok(())
}
}
This is the same Tui
struct we used in initialize_panic_handler()
. We call Tui::exit()
before printing the stacktrace.
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:
fn main() -> Result<()> {
let mut app = App::new();
let mut t = Tui::new()?;
t.enter()?; // raw mode enabled
loop {
// get key event and update state
// ... Special handling to read key or mouse events required here
t.terminal.draw(|f| { // <- `terminal.draw` is the only ratatui function here
ui(app, f) // render state to terminal
})?;
}
t.exit()?; // raw mode disabled
Ok(())
}
The terminal.draw(|f| { ui(app, f); })
call is the only line in the code above
that uses ratatui
functionality.
You can learn more about
draw
from the official documentation.
Essentially, terminal.draw()
takes a callback that takes a
Frame
and expects
the callback to render widgets to that frame, which is then drawn to the terminal
using a double buffer technique.
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
.
fn main() -> Result {
let mut app = App::new();
let mut t = Tui::new()?;
t.enter()?;
loop {
if crossterm::event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = crossterm::event::read()? {
match key.code {
KeyCode::Char('j') => app.increment(),
KeyCode::Char('k') => app.decrement(),
KeyCode::Char('q') => break,
_ => (),
}
}
};
t.terminal.draw(|f| {
ui(app, f)
})?;
}
t.exit()?;
Ok(())
}
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
enum Event {
Key(crossterm::event::KeyEvent)
}
struct EventHandler {
rx: std::sync::mpsc::Receiver<Event>,
}
impl EventHandler {
fn new() -> Self {
let tick_rate = std::time::Duration::from_millis(250);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
loop {
if crossterm::event::poll(tick_rate)? {
match crossterm::event::read()? {
CrosstermEvent::Key(e) => tx.send(Event::Key(e)),
_ => unimplemented!(),
}?
}
}
})
EventHandler { rx }
}
fn next(&self) -> Result<Event> {
Ok(self.rx.recv()?)
}
}
tokio::task
enum Event {
Key(crossterm::event::KeyEvent)
}
struct EventHandler {
rx: tokio::sync::mpsc::UnboundedReceiver<Event>,
}
impl EventHandler {
fn new() -> Self {
let tick_rate = std::time::Duration::from_millis(250);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
loop {
if crossterm::event::poll(tick_rate)? {
match crossterm::event::read()? {
CrosstermEvent::Key(e) => tx.send(Event::Key(e)),
_ => unimplemented!(),
}?
}
}
})
EventHandler { rx }
}
async fn next(&self) -> Result<Event> {
Ok(self.rx.recv().await.ok()?)
}
}
diff
enum Event {
Key(crossterm::event::KeyEvent)
}
struct EventHandler {
- rx: std::sync::mpsc::Receiver<Event>,
+ rx: tokio::sync::mpsc::UnboundedReceiver<Event>,
}
impl EventHandler {
fn new() -> Self {
let tick_rate = std::time::Duration::from_millis(250);
- let (tx, rx) = std::sync::mpsc::channel();
+ let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
- std::thread::spawn(move || {
+ tokio::spawn(async move {
loop {
if crossterm::event::poll(tick_rate)? {
match crossterm::event::read()? {
CrosstermEvent::Key(e) => tx.send(Event::Key(e)),
_ => unimplemented!(),
}?
}
}
})
EventHandler { rx }
}
- fn next(&self) -> Result<Event> {
+ async fn next(&self) -> Result<Event> {
- Ok(self.rx.recv()?)
+ Ok(self.rx.recv().await.ok()?)
}
}
A lot of examples out there in the wild might use the following code for sending key presses:
CrosstermEvent::Key(e) => tx.send(Event::Key(e)),
However, on Windows, when using Crossterm
, this will send the same Event::Key(e)
twice; one for when you press the key, i.e. KeyEventKind::Press
and one for when you release the key, i.e. KeyEventKind::Release
.
On MacOS
and Linux
only KeyEventKind::Press
kinds of key
event is generated.
To make the code work as expected across all platforms, you can do this instead:
CrosstermEvent::Key(key) => {
if key.kind == KeyEventKind::Press {
event_tx.send(Event::Key(key)).unwrap();
}
},
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:
#[tokio::main]
async fn main() -> {
let mut app = App::new();
let events = EventHandler::new();
let mut t = Tui::new()?;
t.enter()?;
loop {
if let Event::Key(key) = events.next().await? {
match key.code {
KeyCode::Char('j') => app.increment(),
KeyCode::Char('k') => app.decrement(),
KeyCode::Char('q') => break,
_ => (),
}
}
t.terminal.draw(|f| {
ui(app, f)
})?;
}
t.exit()?;
Ok(())
}
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:
use color_eyre::eyre::Result;
use crossterm::{
cursor,
event::{Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent},
};
use futures::{FutureExt, StreamExt};
use tokio::{
sync::{mpsc, oneshot},
task::JoinHandle,
};
#[derive(Clone, Copy, Debug)]
pub enum Event {
Error,
AppTick,
Key(KeyEvent),
}
#[derive(Debug)]
pub struct EventHandler {
_tx: mpsc::UnboundedSender<Event>,
rx: mpsc::UnboundedReceiver<Event>,
task: Option<JoinHandle<()>>,
stop_cancellation_token: CancellationToken,
}
impl EventHandler {
pub fn new(tick_rate: u64) -> Self {
let tick_rate = std::time::Duration::from_millis(tick_rate);
let (tx, rx) = mpsc::unbounded_channel();
let _tx = tx.clone();
let stop_cancellation_token = CancellationToken::new();
let _stop_cancellation_token = stop_cancellation_token.clone();
let task = tokio::spawn(async move {
let mut reader = crossterm::event::EventStream::new();
let mut interval = tokio::time::interval(tick_rate);
loop {
let delay = interval.tick();
let crossterm_event = reader.next().fuse();
tokio::select! {
_ = _stop_cancellation_token.cancelled() => {
break;
}
maybe_event = crossterm_event => {
match maybe_event {
Some(Ok(evt)) => {
match evt {
CrosstermEvent::Key(key) => {
if key.kind == KeyEventKind::Press {
tx.send(Event::Key(key)).unwrap();
}
},
_ => {},
}
}
Some(Err(_)) => {
tx.send(Event::Error).unwrap();
}
None => {},
}
},
_ = delay => {
tx.send(Event::AppTick).unwrap();
},
}
}
});
Self { _tx, rx, task: Some(task), stop_cancellation_token }
}
pub async fn next(&mut self) -> Option<Event> {
self.rx.recv().await
}
pub async fn stop(&mut self) -> Result<()> {
self.stop_cancellation_token.cancel();
if let Some(handle) = self.task.take() {
handle.await.unwrap();
}
Ok(())
}
}
Using crossterm::event::EventStream::new()
requires the event-stream
feature to be enabled.
crossterm = { version = "0.26.1", default-features = false, features = ["event-stream"] }
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.
use std::{
ops::{Deref, DerefMut},
time::Duration,
};
use color_eyre::eyre::Result;
use crossterm::{
cursor,
event::{Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent},
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
};
use futures::{FutureExt, StreamExt};
use ratatui::backend::CrosstermBackend as Backend;
use serde::{Deserialize, Serialize};
use tokio::{
sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
task::JoinHandle,
};
use tokio_util::sync::CancellationToken;
pub type Frame<'a> = ratatui::Frame<'a, Backend<std::io::Stderr>>;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Event {
Init,
Quit,
Error,
Closed,
Tick,
Render,
FocusGained,
FocusLost,
Paste(String),
Key(KeyEvent),
Mouse(MouseEvent),
Resize(u16, u16),
}
pub struct Tui {
pub terminal: ratatui::Terminal<Backend<std::io::Stderr>>,
pub task: JoinHandle<()>,
pub cancellation_token: CancellationToken,
pub event_rx: UnboundedReceiver<Event>,
pub event_tx: UnboundedSender<Event>,
pub frame_rate: f64,
pub tick_rate: f64,
}
impl Tui {
pub fn new() -> Result<Self> {
let tick_rate = 4.0;
let frame_rate = 60.0;
let terminal = ratatui::Terminal::new(Backend::new(std::io::stderr()))?;
let (event_tx, event_rx) = mpsc::unbounded_channel();
let cancellation_token = CancellationToken::new();
let task = tokio::spawn(async {});
Ok(Self { terminal, task, cancellation_token, event_rx, event_tx, frame_rate, tick_rate })
}
pub fn tick_rate(&mut self, tick_rate: f64) {
self.tick_rate = tick_rate;
}
pub fn frame_rate(&mut self, frame_rate: f64) {
self.frame_rate = frame_rate;
}
pub fn start(&mut self) {
let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate);
let render_delay = std::time::Duration::from_secs_f64(1.0 / self.frame_rate);
self.cancel();
self.cancellation_token = CancellationToken::new();
let _cancellation_token = self.cancellation_token.clone();
let _event_tx = self.event_tx.clone();
self.task = tokio::spawn(async move {
let mut reader = crossterm::event::EventStream::new();
let mut tick_interval = tokio::time::interval(tick_delay);
let mut render_interval = tokio::time::interval(render_delay);
_event_tx.send(Event::Init).unwrap();
loop {
let tick_delay = tick_interval.tick();
let render_delay = render_interval.tick();
let crossterm_event = reader.next().fuse();
tokio::select! {
_ = _cancellation_token.cancelled() => {
break;
}
maybe_event = crossterm_event => {
match maybe_event {
Some(Ok(evt)) => {
match evt {
CrosstermEvent::Key(key) => {
if key.kind == KeyEventKind::Press {
_event_tx.send(Event::Key(key)).unwrap();
}
},
CrosstermEvent::Mouse(mouse) => {
_event_tx.send(Event::Mouse(mouse)).unwrap();
},
CrosstermEvent::Resize(x, y) => {
_event_tx.send(Event::Resize(x, y)).unwrap();
},
CrosstermEvent::FocusLost => {
_event_tx.send(Event::FocusLost).unwrap();
},
CrosstermEvent::FocusGained => {
_event_tx.send(Event::FocusGained).unwrap();
},
CrosstermEvent::Paste(s) => {
_event_tx.send(Event::Paste(s)).unwrap();
},
}
}
Some(Err(_)) => {
_event_tx.send(Event::Error).unwrap();
}
None => {},
}
},
_ = tick_delay => {
_event_tx.send(Event::Tick).unwrap();
},
_ = render_delay => {
_event_tx.send(Event::Render).unwrap();
},
}
}
});
}
pub fn stop(&self) -> Result<()> {
self.cancel();
let mut counter = 0;
while !self.task.is_finished() {
std::thread::sleep(Duration::from_millis(1));
counter += 1;
if counter > 50 {
self.task.abort();
}
if counter > 100 {
log::error!("Failed to abort task in 100 milliseconds for unknown reason");
break;
}
}
Ok(())
}
pub fn enter(&mut self) -> Result<()> {
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?;
self.start();
Ok(())
}
pub fn exit(&mut self) -> Result<()> {
self.stop()?;
if crossterm::terminal::is_raw_mode_enabled()? {
self.flush()?;
crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, cursor::Show)?;
crossterm::terminal::disable_raw_mode()?;
}
Ok(())
}
pub fn cancel(&self) {
self.cancellation_token.cancel();
}
pub fn suspend(&mut self) -> Result<()> {
self.exit()?;
#[cfg(not(windows))]
signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?;
Ok(())
}
pub fn resume(&mut self) -> Result<()> {
self.enter()?;
Ok(())
}
pub async fn next(&mut self) -> Option<Event> {
self.event_rx.recv().await
}
}
impl Deref for Tui {
type Target = ratatui::Terminal<Backend<std::io::Stderr>>;
fn deref(&self) -> &Self::Target {
&self.terminal
}
}
impl DerefMut for Tui {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.terminal
}
}
impl Drop for Tui {
fn drop(&mut self) {
self.exit().unwrap();
}
}
In the next section, we will introduce a Command
pattern to bridge handling the effect of an
event.