Text-mode Graphics, in Rust, using 'termion'

termion - Drawing to the Terminal Screen

Install termion

You've learned how to do simple text-mode graphics using the "native" ANSI Escape Sequences, and using ncurses.

Now we'll look at doing text-mode graphics with the termion crate. This crate is written in Rust, so it's perhaps the most "native" of "higher-level" Terminal User Interfaces (TUIs), with ANSI being a lower-level.

Like we did with ncurses, we'll need to add the crate (which, like ncurses, is available from "crates.io") to our project. But first, to keep it separate from other earlier work we've done, let's create a new project.

Move into the "~/projects/RUST" directory and run:

$ cargo new termion_experiment

Now take a look at the just-created "Cargo.toml" file.

~/projects/RUST/$ cd termion_experiment
~/projects/RUST/termion_experiment$ cat Cargo.toml
Cargo.toml
[package]
name = "termion_experiment"
version = "0.1.0"
edition = "2021"

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

[dependencies]

The above is mine before any changes have been made to it.

To add the termion installation line in "Cargo.toml", we could manually edit the file to add this line, or we can let Cargo do the work for us, with this command:

$ cargo add termion

which adds a dependency line like so:

Cargo.toml
...
[dependencies]
termion = "2.0.1"

Now you have told Cargo that "termion" of a certain version number is needed by this "termion_experiment" project, and it will go get it from "crates.io" the next time you try to compile "main.rs". Let's do that now. You know the procedure:

$ cargo run

Your results should look something like this:

/projects/RUST/termion_experiment$ cargo run
   Compiling libc v0.2.149
   Compiling numtoa v0.1.0
   Compiling termion v2.0.1
   Compiling termion_experiment v0.1.0 (/home/westk/projects/RUST/termion_experiment)
    Finished dev [unoptimized + debuginfo] target(s) in 1.05s
     Running `target/debug/termion_experiment`
Hello, world!
~/projects/RUST/termion_experiment$

Use termion to clear the screen.

If you compile and run the "main.rs" program as it is, it doesn't first clear the screen. We can use the termion crate to clear the screen before displaying "Hello, World!"

src/main.rs
fn main() {

    // Clear the screen.
    print!("{}", termion::clear::All);
    
    println!("Hello, world!");
} // end of main()

Notice that in the "print!" statement we had to use the entire path to the "All" function, starting at "termion", then going to "clear", and then on to "All". Soon we'll use some "use" statements to simplify these statements.

Unlike with ncurses, we don't need to do any special housekeeping/setup steps when using termion for text-mode graphics.

Use termion to Add Some Color

Suppose you wanted to add a little color.

src/main.rs
fn main() {

    // Clear the screen.
    print!("{}", termion::clear::All);
  
    // Add some color.
    let red = termion::color::Fg(termion::color::Red);
    let reset = termion::color::Fg(termion::color::Reset);

    println!("{}Hello,{} world!", red, reset);
} // end of main()

Simplify paths.

Notice that although the path to the "All" function and the path to the "Red" and "Reset" functions start out the same, starting at "termion::", the "All" function descends through the "termion::clear::" path whereas the "Red" and "Reset" and "Fg" (Foreground color) functions descend throught the "termion::color::" path. So if we use a "use" statement to simplify these paths, we'll need several of them, like so:

src/main.rs
use termion::clear::All;
use termion::color::Fg;
use termion::color::Red;
use termion::color::Reset;
  
fn main() {

    // Clear the screen.
    print!("{}", termion::clear::All);

    // Add some color.
    let red = termion::color::Fg(termion::color::Red);
    let reset = termion::color::Fg(termion::color::Reset);

    println!("{}Hello,{} world!", red, reset);
} // end of main()

We can simplify that further:

src/main.rs
use termion::clear::All;
use termion::color::{Red, Reset, Fg}; // Order doesn't matter; could just as easily be "Fg, Red, Reset".
use termion::color::Fg;
use termion::color::Red;
use termion::color::Reset;

fn main() {

    // Clear the screen.
    print!("{}", All);
    
    // Add some color.
    let red = Fg(Red);
    let reset = Fg(Reset);

    println!("{}Hello,{} world!", red, reset);
} // end of main()

If we wanted to add more colors, we could add the color of each name we wanted, or we could just pull all the color names in, like so:

src/main.rs
use termion::clear::All;
use termion::color::{Red, Reset, Fg};
use termion::color::*;

fn main() {

    // Clear the screen.
    print!("{}", All);

    let red = Fg(Red);
    let green = Fg(Green);
    let reset = Fg(Reset);

    println!("{}Hello,{} world!{}", red, green, reset);
} // end of main()

However, notice the line:

print!("{}", All);

If it wasn't for the comment above it, a casual reader of our program (or you, yourself, six months from now), might wonder, "What is this 'All'?" This is a case where it might make for more readable code to leave the path in place, like so:

print!("{}", termion::clear::All);

Doing this, with the rest of the program left as-is, will cause the Cargo compiler to generate a warning that the import "use termion::clear::All" is unused (which you won't see because the screen clears too quickly for us to see that warning). We can ignore that warning, as it's not hurting anything, or we could remove that import.

If you want to see that warning, you can add the following code temporarily:

src/main.rs
...
fn main() {
  
    println!("Press ENTER to continue.");
    let mut line = String::new();
    let input = std::io::stdin().read_line(&mut line).expect("Failed to read line");

    // Clear the screen.
    print!("{}", termion::clear::All);
...

Unless you want to leave in this "Press ENTER to continue", don't forget to remove the above code when you're finished using it.

To remove that now-unused "use termion::clear::All" import, just delete that line, like so:

src/main.rs
use termion::clear::All;
use termion::color::*;
...

Use constants instead of variables.

We can probably save a tiny bit of memory, and slightly improve our run-time speed, by using constants instead of variables. If we declare the constants in the "main()" function, that function is the only place those constants will be known. If we declare them outside any function, they'll be known to all parts of the program within our "main.rs" file, such as in other functions we may later create.

src/main.rs
use termion::color::*;
  
const RED: Fg<Red> = Fg(Red);
const BLUE: Fg<Blue> = Fg(Blue);
const YELLOW: Fg<Yellow> = Fg(Yellow);
const GREEN: Fg<Green> = Fg(Green);
const RESET: Fg<Reset> = Fg(Reset);

fn main() {

    // Clear the screen.
    print!("{}", termion::clear::All);

    // Add some color.
    let red = Fg(Red);
    let green = Fg(Green);
    let reset = Fg(Reset);

    println!("{}H{}ello,{} world{}!{}", red, green, resetBLUE, RED, YELLOW, GREEN, RESET);
} // end of main()

Use Termion to Determine Screen-Size

It's often helpful to know the dimensions of the terminal window in which you're working. Here's how to do that:

src/main.rs
use termion::color::*;

const RED: Fg<Red> = Fg(Red);
const BLUE: Fg<Blue> = Fg(Blue);
const YELLOW: Fg<Yellow> = Fg(Yellow);
const GREEN: Fg<Green> = Fg(Green);
const RESET: Fg<Reset> = Fg(Reset);

fn main() {

    // Clear the screen.
    print!("{}", termion::clear::All);
  
    // Get screen dimensions.
    let (cols, rows) = termion::terminal_size().expect("Error getting terminal size.");
    println!("The screen dimensions are: {} columns x {} rows", cols, rows);

    println!("{}H{}ello,{} world{}!{}", BLUE, RED, YELLOW, GREEN, RESET);} // end of main()

When you compile/run this (cargo run), you should see something like:

The screen dimensions are: 95 columns x 39 rows
Hello, world!
~/projects/RUST/termion_experiment$

Of course, your screensize will likely be different.

The "terminal_size()" function returns a type of data called a "Result". A "Result" type is like an Amazon delivery package with a sign on the outside that either says "Ok" or "Error". If it's "Ok", then the box's contents are what you expect; if it's "Err[or]", then there's a note that explains a little of what went wrong -- "Lost", "Broken", "Shipper went out of business", etc.

In general there are three ways to handle a return type of "Result":

In this case of "terminal_size()", the contents of the Result-type "shipping box" is a "tuple". A tuple, instead of being one value assigned to one variable name, is a collection of more than one value. In this case, it is two values. But "tuple" has no relation to "two"; it could be three values, or thirty, or three-hundred. In our case with "terminal_size()", we're getting back a tuple of two values, which we're binding to two variable names, "cols" and "rows".

Then we're printing those two values.

If we wanted to, instead of breaking up the tuple into two variables, we could leave it as a tuple, and give the tuple a name like "screen-dimensions" (or "size", or "dims", or whatever), and then access the contents as ".0" and ".1". (If there was a third element to the tuple, it would be accessed with ".2", etc.) That would look something like this:

src/main.rs
...
    let (cols, rows) = termion::terminal_size().expect("Error getting terminal size.");
    println!("The screensize = {} columns x {} rows.", cols, rows);

    let screen_size = termion::terminal_size().expect("Error getting terminal size.");
    let cols = screen_size.0;
    let rows = screen_size.1;
    println!("The screensize = {} columns x {} rows.", cols, rows);
    
    // Or we could bypass the extra variable bindings and just do:
    println!("The screensize = {} columns x {} rows.", screen_size.0, screen_size.1);
...

Using the discrete values of "cols" and "rows" is more self-documenting, but a tuple can be a little easier to pass around into functions and etc. Pros and cons either way. For now at least, I'll stick to the discrete values.

Move the Cursor to Where You Want It

If you wanted to draw an asterisk in the center of the screen, you could do this:

src/main.rs
...
fn main() {

    // Clear the screen.
    print!("{}", termion::clear::All);

    // Get screen dimensions.
    let (cols, rows) = termion::terminal_size().expect("Error getting terminal size.");
    println!("The screensize = {} columns x {} rows.", cols, rows);

    // Print an asterisk in center of screen.
    let rows_center = rows / 2;
    let cols_center = cols / 2;
    print!("{}", termion::cursor::Goto(cols_center, rows_center));
    print!("*");
    
    // Send the cursor to the bottom-left of screen.
    print!("{}", termion::cursor::Goto(1, rows));

    println!("{}H{}ello,{} world{}!{}", BLUE, RED, YELLOW, >
} // end of main()

If you don't want to specify the "termion::" path on the "cursor" lines, you could modify the "use" statement:

src/main.rs
use termion::{color::*, cursor};
...
print!("{}, termion::cursor::Goto(1, rows));
...

For reference, here's the entire program.

src/main.rs
use termion::color::*;

const RED: Fg<Red> = Fg(Red);
const BLUE: Fg<Blue> = Fg(Blue);
const YELLOW: Fg<Yellow> = Fg(Yellow);
const GREEN: Fg<Green> = Fg(Green);
const RESET: Fg<Reset> = Fg(Reset);

fn main() {

    // Clear the screen.
    print!("{}", termion::clear::All);

    // Get screen dimensions.
    let (cols, rows) = termion::terminal_size().expect("Error getting terminal size.");
    println!("The screen dimensions are: {} columns x {} rows", cols, rows);

    // Print an asterisk in center of screen.
    let rows_center = rows / 2;
    let cols_center = cols / 2;
    print!("{}", termion::cursor::Goto(cols_center, rows_center));
    print!("*");

    // Send the cursor to the bottom-left of screen.
    print!("{}", termion::cursor::Goto(1, rows));
    println!("{}H{}ello,{} world{}!{}", BLUE, RED, YELLOW, GREEN, RESET);
} // end of main()

Use UTF-8 Characters

A pretty big advantage of termion over ncurses is that termion is UTF-8-friendly. This means you can print many more characters than what is in the standard ASCII chart. For example, let's replace the asterisk in the above program with a tiny airplane. NOTE: Not all editors (e.g., nano) will recognize the Ctrl-Shift-U key-sequence; in such a case, just copy/paste the symbol from below.

src/main.rs
...
print!("{}", termion::cursor::Goto(cols_center, rows_center));
print!("*");
print!("✈"); // Create a UTF-8 char with Ctrl-Shift-U, then the character code (2708), then ENTER.
...

Animate the cursor across the screen

Suppose we wanted to fly that tiny airplane across the screen, left to right. We just set up a loop to move the cursor across the screen, print the plane, put in a slight delay so our eyes can register the plane before it moves on to the next space, print a space over the plane to erase it, and we're done.

src/main.rs
use termion::color::*;

const RED: Fg<Red> = Fg(Red);
const BLUE: Fg<Blue> = Fg(Blue);
const YELLOW: Fg<Yellow> = Fg(Yellow);
const GREEN: Fg<Green> = Fg(Green);
const RESET: Fg<Reset> = Fg(Reset);

fn main() {

    // Clear the screen.
    print!("{}", termion::clear::All);

    // Get screen dimensions.
    let (cols, rows) = termion::terminal_size().expect("Error getting terminal size.");
    println!("The screen dimensions are: {} columns x {} rows", cols, rows);
    
    // Print an asterisk in center of screen.
    let rows_center = rows / 2;
    let cols_center = cols / 2;
    print!("{}", termion::cursor::Goto(cols_center, rows_center));
    print!("✈");

    // Fly the plane at the center row.
    let altitude = rows / 2;
    
    // Delay between animation frames.
    let delay = 4000000;
    
    // Move the plane from left column to right.
    for column in 1..cols {
        // Move to the correct column and print the plane.
        print!("{}", termion::cursor::Goto(column, altitude));
        print!("✈"); 
        // Delay long enough for our eyes to register the plane.
        for _delay_loop in 0..delay {};  
        // Move back to that same column and erase the plane.
        print!("{}", termion::cursor::Goto(column, altitude));
        print!(" ");
    }

  
    // Send the cursor to the bottom-left of screen.
    print!("{}", termion::cursor::Goto(1, rows));
    println!("{}H{}ello,{} world{}!{}", BLUE, RED, YELLOW, GREEN, RESET);
} // end of main()

If you run this program as it, you may be surprised to see that it doesn't work. You never see the plane printed on the screen. Whaa-aa-aah-h?

There's a feature about the "print!()" and "println!()" macros that often confuses newcomers to Rust. "print!()" does not actually display output to the terminal window. Instead, it (like "println!()" does also) buffers its output in a "buffer". Then when a certain trigger occurs, that buffer is flushed to the terminal, and it is at that point that the printed material actually shows up on the terminal window. One of the most common triggers that flushes this output buffer, and the trigger that causes the "println!()" macro to immediately display output, is a newline.

So the easy fix for our program above is to add a newline when we want the buffer flushed to the terminal window. We could convert all the "print!()" statements to "println!()"; or we could convert just one or two as needed to accomplish what we want to do. Or we could add a "\n" to one or more of our print statements.

src/main.rs
...
    for column in 1..cols {
        // Move to the correct column and print the plane.
        print!("{}", termion::cursor::Goto(column, altitude));
        println!("✈");
...

The reason I did not use "println!()" throughout is because that would move the insertion point (the "cursor") down one line and all the way to the left of the screen, which would then mean moving the insertion point farther than necessary when it may need to be up a line and farther to the right. This could, in theory at least, save some execution time. If this were a game, you might like that little extra speed savings in your gameplay. In our case, with the program written as it is, it's not really a concern. Modifications could be made which might make the program more efficient, but it's efficient enough as-is, and pretty understandable as-is, so we won't try for those mods. However, you may want to experiment with the below mods to see what happens in each case.

src/main.rs
...
    // Move the plane from left column to right.
    for column in 1..cols {
        // Move to the correct column and print the plane.
        println!("{}", termion::cursor::Goto(column, altitude));
        println!("✈"); 
        // Delay long enough for our eyes to register the plane.
        for _delay_loop in 0..delay {};  
        // Move back to that same column and erase the plane.
        println!("{}", termion::cursor::Goto(column, altitude));
        println!(" ");
    }
...
src/main.rs
...
    // Move the plane from left column to right.
    for column in 1..cols {
        // Move to the correct column and print the plane.
        print!("{}", termion::cursor::Goto(column, altitude));
        println!("✈"); 
        // Delay long enough for our eyes to register the plane.
        for _delay_loop in 0..delay {};  
        // Move back to that same column and erase the plane.
        print!("{}", termion::cursor::Goto(column, altitude));
        println!(" ");
    }
...
src/main.rs
...
    // Move the plane from left column to right.
    for column in 1..cols {
        // Move to the correct column and print the plane.
        print!("{}", termion::cursor::Goto(column, altitude));
        println!("✈"); 
        // Delay long enough for our eyes to register the plane.
        for _delay_loop in 0..delay {};  
        // Move back to that same column and erase the plane.
        print!("{}", termion::cursor::Goto(column, altitude));
        print!(" ");
    }
...
src/main.rs
...
    // Move the plane from left column to right.
    for column in 1..cols {
        // Move to the correct column and print the plane.
        println!("{}", termion::cursor::Goto(column, altitude));
        print!("✈");
        // Delay long enough for our eyes to register the plane.
        for _delay_loop in 0..delay {};  
        // Move back to that same column and erase the plane.
        println!("{} ", termion::cursor::Goto(column, altitude));
        print!(" ");
    }
...

Get rid of the block cursor

When you run the program, you may have noticed the insertion point ("cursor") hanging out on the left-hand side of the window as the plane flies across. Kindda ugly. Let's turn that off:

src/main.rs
use termion::color::*;

const RED: Fg<Red> = Fg(Red);
const BLUE: Fg<Blue> = Fg(Blue);
const YELLOW: Fg<Yellow> = Fg(Yellow);
const GREEN: Fg<Green> = Fg(Green);
const RESET: Fg<Reset> = Fg(Reset);

fn main() {

    // Clear the screen.
    print!("{}", termion::clear::All);

    // Get screen dimensions.
    let (cols, rows) = termion::terminal_size().expect("Error getting terminal size.");
    println!("The screen dimensions are: {} columns x {} rows", cols, rows);
    
    // Fly the plane at the center row.
    let altitude = rows / 2;
    
    // Delay between animation frames.
    let delay = 4000000;
    
    // Turn off display of cursor.
    println!("{}", termion::cursor::Hide);

    // Move the plane from left column to right.
    for column in 1..cols {
        // Move to the correct column and print the plane.
        println!("{}✈", termion::cursor::Goto(column, altitude));
        // Delay long enough for our eyes to register the plane.
        for _delay_loop in 0..delay {};  
        // Move back to that same column and erase the plane.
        println!("{} ", termion::cursor::Goto(column, altitude));
    }

    // Send the cursor to the bottom-left of screen.
    print!("{}", termion::cursor::Goto(1, rows));
    println!("{}H{}ello,{} world{}!{}", BLUE, RED, YELLOW, GREEN, RESET);
} // end of main()

There, that's better.

A better delay

The loop we used for a delay is just a quick-and-dirty method of wasting some CPU cycles to put a slight delay into the loop for moving the plane across the screen. But Rust has a better mechanism than that, which is dependent on actual time passed, rather than simply counting to some number repeatedly. If you have read the lesson Text-Mode Graphics, in Rust, using 'curses', you should already be familiar with this function.

src/main.rs
use termion::color::*;

const RED: Fg<Red> = Fg(Red);
const BLUE: Fg<Blue> = Fg(Blue);
const YELLOW: Fg<Yellow> = Fg(Yellow);
const GREEN: Fg<Green> = Fg(Green);
const RESET: Fg<Reset> = Fg(Reset);

const MILLISECONDS:u64 = 10; // The higher the number the slower the animation.

fn main() {

    // Clear the screen.
    print!("{}", termion::clear::All);

    // Get screen dimensions.
    let (cols, rows) = termion::terminal_size().expect("Error getting terminal size.");
    println!("The screen dimensions are: {} columns x {} rows", cols, rows);
    
    // Fly the plane at the center row.
    let altitude = rows / 2;
    
    // Delay between animation frames.
    let delay = 4000000;

    // Turn off display of cursor.
    println!("{}", termion::cursor::Hide);

    // Move the plane from left column to right.
    for column in 1..cols {
        // Move to the correct column and print the plane.
        println!("{}✈", termion::cursor::Goto(column, altitude));
        // Delay long enough for our eyes to register the plane.
        for _delay_loop in 0..delay {};
        std::thread::sleep(std::time::Duration::from_millis(MILLISECONDS));
        // Move back to that same column and erase the plane.
        println!("{} ", termion::cursor::Goto(column, altitude));
    }

    // Send the cursor to the bottom-left of screen.
    print!("{}", termion::cursor::Goto(1, rows));
    println!("{}H{}ello,{} world{}!{}", BLUE, RED, YELLOW, GREEN, RESET);
} // end of main()

You can simplify the paths in the program code a bit with a "use" statement:

src/main.rs
use std::{thread::sleep, time::Duration};
use termion::color::*;

const RED: Fg<Red> = Fg(Red);
const BLUE: Fg<Blue> = Fg(Blue);
const YELLOW: Fg<Yellow> = Fg(Yellow);
const GREEN: Fg<Green> = Fg(Green);
const RESET: Fg<Reset> = Fg(Reset);

const MILLISECONDS:u64 = 10; // The higher the number the slower the animation.
const MILLISECONDS: Duration = Duration::from_millis(10); // The higher the number the slower the animation.

fn main() {

    // Clear the screen.
    print!("{}", termion::clear::All);

    // Get screen dimensions.
    let (cols, rows) = termion::terminal_size().expect("Error getting terminal size.");
    println!("The screen dimensions are: {} columns x {} rows", cols, rows);
    
    // Fly the plane at the center row.
    let altitude = rows / 2;
    
    // Turn off display of cursor.
    println!("{}", termion::cursor::Hide);

    // Move the plane from left column to right.
    for column in 1..cols {
        // Move to the correct column and print the plane.
        println!("{}✈", termion::cursor::Goto(column, altitude));
        // Delay long enough for our eyes to register the plane.
        std::thread::sleep(std::time::Duration::from_millis(MILLISECONDS));
        sleep(MILLISECONDS);
        // Move back to that same column and erase the plane.
        println!("{} ", termion::cursor::Goto(column, altitude));
    }

    // Send the cursor to the bottom-left of screen.
    print!("{}", termion::cursor::Goto(1, rows));
    println!("{}H{}ello,{} world{}!{}", BLUE, RED, YELLOW, GREEN, RESET);
} // end of main()

Keyboard Input - Raw Mode

Raw Mode makes several changes to the way the terminal behaves. As Ticki wrote six years ago, it makes these changes:

As regards that mention of "canonicalization", here's what Packt has to say:

There are two modes in which terminals can operate:
src/main.rs
use std::{thread::sleep, time::Duration, io::{Write, stdout}};
use std::{thread::sleep, time::Duration};
use termion::{color::*, raw::IntoRawMode};
use termion::color::*;


const RED: Fg<Red> = Fg(Red);
const BLUE: Fg<Blue> = Fg(Blue);
const YELLOW: Fg<Yellow> = Fg(Yellow);
const GREEN: Fg<Green> = Fg(Green);
const RESET: Fg<Reset> = Fg(Reset);

const MILLISECONDS: Duration = Duration::from_millis(10); // The higher the number the slower the animation.

fn main() {

    // Enter raw mode.
    let mut stdout = stdout().into_raw_mode().unwrap();
  
    // Clear the screen.
    print!("{}", termion::clear::All);
    writeln!(stdout, "{}", termion::clear::All).unwrap();

    // Get screen dimensions.
    let (cols, rows) = termion::terminal_size().expect("Error getting terminal size.");
    println!("The screen dimensions are: {} columns x {} rows", cols, rows);
    
    // Fly the plane at the center row.
    let altitude = rows / 2;
    
    // Turn off display of cursor.
    println!("{}", termion::cursor::Hide);

    // Move the plane from left column to right.
    for column in 1..cols {
        // Move to the correct column and print the plane.
        println!("{}✈", termion::cursor::Goto(column, altitude));
        // Delay long enough for our eyes to register the plane.
        sleep(MILLISECONDS);
        // Move back to that same column and erase the plane.
        println!("{} ", termion::cursor::Goto(column, altitude));
    }

    // Send the cursor to the bottom-left of screen.
    print!("{}", termion::cursor::Goto(1, rows));
    println!("{}H{}ello,{} world{}!{}", BLUE, RED, YELLOW, GREEN, RESET);
} // end of main()