TEK2049

Solving Problems with Terminal Graphics

On building console applications with tui and Rust

Today when developers are thinking about programs with graphical interfaces, they tend to go with technologies that are JavaScript based like Vue and React. For simple people it’s just plain old server-side rendering with Python or PHP behind the scenes. And serious people who don’t default to web technologies would probably choose GTK3 or Qt.

All this is fine, and each technology has its use-cases where it shines (well, ... except React). However, I offer you to consider another option; building your UI application entirely in the terminal. You won’t need to compromise on user experience, in fact, for some use-cases you might find that the terminal makes much more sense!


Historically terminal based applications were not the easiest thing to begin with. You didn’t get a proper event bus, while there are libraries like ncurses that make stuff much easier, it's still not the simple API you might expect today, and the latest documentation is from 2005.

Disclaimer about Rust

Rust is a good language that can fit a lot of use-cases. From embedded devices, kernel, and low level system components to graphic engines, user interface frameworks, and basically anything else in between and around. But it's especially no-brainer when it comes to building stuff that otherwise you would build in high-level C like terminal programs.

In this tutorial-like article where I will try to help you build an interactive terminal application using the tui library for rich terminal interfaces. This is not, however, a Rust language tutorial, and I will not get into the details why I did stuff a certain way, with the Rust idiom in mind.

Rust is not the easiest language to learn, but I would strongly recommend any software/embedded developer to learn it. Even if you’re not going to use it, learning a functional language just makes you a better programmer and opens your mind to new ideas. You’re still here? Good. You can use this cheat-sheet for Rust if you don’t remember everything.

Do not try to run the compiled executable from your IDE, it would probably won’t work and you’ll get ‘No such device or address’ error. This is okay, just run it inside your normal terminal, it cannot work with the virtual terminal stuff.

Building the application from bottom to top

The application is made out of five small code modules:
main.rs, event.rs, gui.rs, time.rs, utility.rs
I split them like that, to give you some framework like structure you can start with and expand later. Additionally there is also Cargo.toml that defines project settings and dependencies. I will begin with the project file, it would be a good place to start.

Cargo.toml

Here we define the basic dependencies required for working with tui. It supports a few backend terminal libraries crossterm and termion, both are good and solid options, but I found that crossterm works good on windows as well so I will use that one.

[package]
name = "rust-terminal-widgets"
version = "0.1.0"
edition = "2021"
description = "A set of terminal based widgets based on tui."

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
crossterm = "0.24"
tui = { version = "0.18", default-features = false, features = ["crossterm"] }
log = "0.4"
simple-logging = "2.0"

event.rs

This part is not trivial for many, and it's essential for building proper terminal applications. It would probably have taken you a while to get to this if building your own program from scratch. Since I already built a terminal based strategy game that requires decoupling of Update, Draw, Input, and Load. I thought it's a good idea to just use that for any application:

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

pub enum EventType {
    Update,
    Draw,
    Input,
    Load,
}

pub struct EventBus {
    rx: mpsc::Receiver,
    update_handle: thread::JoinHandle<()>,
    draw_handle: thread::JoinHandle<()>,
    input_handle: thread::JoinHandle<()>,
    load_handle: thread::JoinHandle<()>,
}

#[derive(Debug, Clone, Copy)]
pub struct Config {
    pub update_rate: Duration,
    pub draw_rate: Duration,
    pub input_rate: Duration,
    pub load_rate: Duration,
}

impl Default for Config {
    fn default() -> Config {
        Config {
            update_rate: Duration::from_millis(240),
            draw_rate: Duration::from_millis(30),
            input_rate: Duration::from_millis(10),
            load_rate: Duration::from_millis(1000),
        }
    }
}

impl EventBus {
    pub fn new() -> EventBus {
        EventBus::with_config(Config::default())
    }

    pub fn with_config(config: Config) -> EventBus {
        let (tx, rx) = mpsc::channel();

        let update_handle = {
            let tx = tx.clone();

            thread::spawn(move || loop {
                if tx.send(EventType::Update).is_err() {
                    break;
                }
                thread::sleep(config.update_rate);
            })
        };

        let draw_handle = {
            let tx = tx.clone();

            thread::spawn(move || loop {
                if tx.send(EventType::Draw).is_err() {
                    break;
                }
                thread::sleep(config.draw_rate);
            })
        };

        let input_handle = {
            let tx = tx.clone();

            thread::spawn(move || loop {
                if tx.send(EventType::Input).is_err() {
                    break;
                }
                thread::sleep(config.input_rate);
            })
        };

        let load_handle = {
            let tx = tx.clone();

            thread::spawn(move || loop {
                if tx.send(EventType::Load).is_err() {
                    break;
                }
                thread::sleep(config.load_rate);
            })
        };

        EventBus {
            rx,
            update_handle,
            draw_handle,
            input_handle,
            load_handle,
        }
    }

    pub fn next(&self) -> Result {
        self.rx.recv()
    }
}

EventType defines the 4 event types (you can add more later if you need), each event handler is running in a thread, and each one has its own duration when it's triggered. You probably wouldn’t need to touch this file except when you need to add new event types.

time.rs

Here I put all time and interval related methods, you might need more stuff here, but to make things simple I implement just a single time related structure Tick. It’s pretty straightforward, additionally to new() you have update() that update the ticker delta with the current time and saves the timestamp, finally, delta() would just return the delta from last tick.

use std::time::Duration;

pub struct Tick {
    delta: u128,
    duration: Duration,
}

impl Tick {
    pub fn new() -> Tick {
        let delta = 0;
        let duration = Duration::from_millis(0);

        return Tick { delta, duration };
    }

    pub fn update(&mut self, elapsed: &Duration) {
        self.delta = elapsed.as_millis() - self.duration.as_millis();
        self.duration = elapsed.clone();
    }

    pub fn delta(&self) -> u128 {
        return self.delta;
    }
}

utility.rs

You would probably want to put more stuff here. For now it contains only one method that reverses log lines from newest to oldest, with a line number limit MAX_CONSOLE_LINES that’s defined in the module and set to 20 by default, and joins it back into a string buffer.

use std::fs::read_to_string;

const MAX_CONSOLE_LINES: usize = 20;

pub fn read_log_lines_reverse(buffer: &mut String, filename: &str) {
    buffer.clear();

    for (i, line) in read_to_string(filename).unwrap().lines().rev().enumerate() {
        if i >= MAX_CONSOLE_LINES {
            break;
        }

        buffer.push_str(line);
        buffer.push_str("\n");
    }
}

gui.rs

This file contains builder-like and utility methods that are related to the tui framework, containers, lists, etc... Not much to say about this file, the framework code is simple and this should be easy to understand.

use tui::layout::{Constraint, Direction, Layout, Rect};
use tui::style::{Color, Style};
use tui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};

pub fn build_main_layout(area: Rect, margin: u16) -> Vec {
    Layout::default()
        .direction(Direction::Horizontal)
        .margin(margin)
        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
        .split(area)
}

pub fn build_container_block<'a>(title: &str) -> Block<'a> {
    let style = Style::default().fg(Color::White);

    Block::default()
        .title(format!(" [ {} ] ", title))
        .borders(Borders::ALL)
        .style(style)
}

pub fn build_text_widget<'a>(text: &'a str, title: &str) -> Paragraph<'a> {
    let block = build_container_block(title);

    let style = Style::default();

    Paragraph::new(text)
        .block(block)
        .style(style)
        .wrap(Wrap { trim: true })
}

pub fn build_list_widget<'a>(strings: &'a Vec, title: &str) -> List<'a> {
    let mut items: Vec = vec![];

    for string in strings {
        items.push(ListItem::new(string.as_str()))
    }

    let block = build_container_block(title);

    let style = Style::default().fg(Color::White);

    let list = List::new(items).block(block).style(style);

    return list;
}

main.rs

Finally we got to the main program file, I will just give it to you, and later will explain some of its parts.

mod event;
mod gui;
mod time;
mod utility;

use log::{debug, error, info, warn, LevelFilter};
use std::error::Error;
use std::io::stdout;
use std::time::{Duration, SystemTime};

use crate::gui::{build_list_widget, build_main_layout, build_text_widget};
use crossterm::event::{poll, read, Event, KeyCode};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use tui::backend::CrosstermBackend;
use tui::Terminal;

use crate::event::{EventBus, EventType};
use crate::time::Tick;
use crate::utility::read_log_lines_reverse;

fn main() -> Result<(), Box> {
    simple_logging::log_to_file("app.log", LevelFilter::Info)?;

    let stdout = stdout();
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    terminal.clear()?; // start with a clean terminal screen
    enable_raw_mode()?; // enable raw mode for proper keyboard input

    let events = EventBus::new();

    let mut update_tick = Tick::new(); // for keeping ui update interval
    let mut draw_tick = Tick::new(); // for keeping ui draw interval
    let mut load_tick = Tick::new(); // for keeping data load interval

    let mut console_buffer = String::new();

    info!("Application initialized, starting main loop ...");

    let mut info_items: Vec = vec![String::new(); 4];

    let now = SystemTime::now(); // record start time

    loop {
        let elapsed = now.elapsed()?;
        let event = events.next()?;

        terminal.draw(|frame| {
            let main_layout = build_main_layout(frame.size(), 0);

            let info_widget = build_list_widget(&info_items, "Info");
            frame.render_widget(info_widget, main_layout[0]);

            let console_widget = build_text_widget(console_buffer.as_str(), "Console");
            frame.render_widget(console_widget, main_layout[1]);
        })?;

        match event {
            EventType::Update => {
                info_items[0] = format!("Time: {:.1} (seconds)", elapsed.as_secs_f32());
                info_items[1] = format!("Draw: {} (ms)", update_tick.delta());
                info_items[2] = format!("Update: {} (ms)", draw_tick.delta());
                info_items[3] = format!("Load: {} (ms)", load_tick.delta());

                update_tick.update(&elapsed);
            }
            EventType::Draw => {
                draw_tick.update(&elapsed);
            }
            EventType::Input => {
                if poll(Duration::from_millis(0))? {
                    // It's guaranteed that the `read()` won't block when the `poll()`
                    // function returns `true`
                    match read()? {
                        Event::Key(event) => {
                            match event.code {
                                KeyCode::Backspace => {}
                                KeyCode::Left => {}
                                KeyCode::Enter => {}
                                KeyCode::Right => {}
                                KeyCode::Up => {}
                                KeyCode::Down => {}
                                KeyCode::Home => {}
                                KeyCode::End => {}
                                KeyCode::PageUp => {}
                                KeyCode::PageDown => {}
                                KeyCode::Tab => {}
                                KeyCode::BackTab => {}
                                KeyCode::Delete => {}
                                KeyCode::Insert => {}
                                KeyCode::F(_) => {}
                                KeyCode::Char(c) => match c {
                                    _ => {}
                                },
                                KeyCode::Null => {}
                                KeyCode::Esc => {
                                    // Quit
                                    disable_raw_mode()?;
                                    terminal.clear()?;
                                    break;
                                }
                            }
                        }
                        Event::Mouse(event) => {
                            info!("mouse event: {:?}", event);
                        }
                        Event::Resize(width, height) => {
                            info!("window resize: ({}x{})", width, height);
                        }
                    }
                }
            }
            EventType::Load => {
                read_log_lines_reverse(&mut console_buffer, "app.log");
                load_tick.update(&elapsed);
            }
        }
    }

    Ok(())
}

To start using tui we create an Stdout object, and pass it to our chosen terminal backend. Now that we got out terminal object we need to clear it, and enable the 'raw' mode - this is done to get the key input correctly.

let stdout = stdout();
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;

terminal.clear()?;
enable_raw_mode()?;

We create an EventBus object to handle events, and anything else we might need during update and draw. Im my case few Tick objects, a string to buffer the console (log) output, and a vector that holds our rendered texts.

let events = EventBus::new();
// ...
let mut update_tick = Tick::new();
// ...
let mut console_buffer = String::new();
// ...
let mut info_items: Vec = vec![String::new(); 4];

I added all key options, I found it's nuc easier to just leave them empty instead of looking every time for the right one.

Event::Key(event) => {
    match event.code {
        KeyCode::Backspace => {}
        KeyCode::Left => {}
        KeyCode::Enter => {}
        // ...
    }
}

I hope that by writing this article I've added another tool to your toolbox when approaching development of graphical user interfaces. Terminal programs done right are great and usually are very convenient. Although I’m not a hardcore vim user or anything, I am always happying to find theres an alternative terminal application for the stuff I do.