Implementing Terminal I/O in Rust

Rust is a modern, open source system programming language that promises the best of three worlds: the type safety of Java; the speed, expressiveness, and efficiency of C++; and memory safety without a garbage collector. In this article, we look at building terminal-based applications in Rust.

Terminal applications are an integral part of many software programs, including games, text editors, and terminal emulators. For developing these types of programs, it helps to understand how to build customized terminal interface-based applications. We will review the basics of how terminals work, and then look at how to perform various types of actions on a terminal, such as setting colors and styles, performing cursor operations (such as clearing and positioning), and working with keyboard and mouse inputs.

We will cover the following topics:

  • Introducing terminal I/O fundamentals
  • Working with the terminal UI (size, color, styles) and cursors
  • Processing keyboard inputs and scrolling
  • Processing mouse inputs

The bulk of the article will be dedicated to explaining these concepts through a practical example. We will build a mini text viewer that will demonstrate key concepts of working with terminals. The text viewer will be able to load a file from disk and display its contents on the terminal interface. It will also allow a user to scroll through the contents using the various arrow keys on the keyboard, and display information on the header and footer bar.

Technical requirements

For those working on the Windows platform, a virtual machine needs to be installed, as the third-party crate used for terminal management does not support the Windows platform (at the time of writing). It is recommended to install a virtual machine such as VirtualBox or equivalent running Linux for working with the code. Instructions to install VirtualBox can be found at https://www.virtualbox.org.

For working with terminals, Rust provides several features to read keypresses and to control standard input and output for a process. When a user types characters in the command line, the bytes generated are available to the program when the user presses the Enter key. This is useful for several types of programs. But for some types of programs, such as games or text editors, which require more fine-grained control, the program must process each character as it is typed by the user, which is also known as raw mode. There are several third-party crates available that make raw mode processing easy. We will be using one such crate, Termion.

Introducing terminal I/O fundamentals

Characteristics of terminals

Originally, UNIX systems were accessed using a terminal (also called a console) connected to a serial line. These terminals typically had a 24 x 80 row x column character-based interface, or, in some cases, had rudimentary graphics capabilities. In order to perform operations on the terminal, such as clearing the screen or moving the cursor, specific escape sequences were used.

There are two modes in which terminals can operate:

  • Canonical mode: In canonical mode, the inputs from the user are processed line by line, and the user has to press the Enter key for the characters to be sent to the program for processing.
  • Non-canonical or raw mode: In raw mode, terminal input is not collected into lines, but the program can read each character as it is typed by the user.

Terminals can be either physical devices or virtual devices. Most terminals today are pseudo-terminals, which are virtual devices that are connected to a terminal device on one side, and to a program that drives the terminal device on the other end. Pseudo-terminals help us write programs where a user on one host machine can execute a terminal-oriented program on another host machine using network communications. An example of a pseudo-terminal application is SSH (Secure Shell Protocol), which allows a user to log in to a remote host over a network.

Terminal management includes the ability to perform the following things on a terminal screen:

  • Color management: Setting various foreground and background colors on the terminal and resetting the colors to default values.
  • Style management: Setting the style of text to bold, italics, underline, and so on.
  • Cursor management: Setting the cursor at a particular position, saving the current cursor position, showing and hiding a cursor, and other special features, such as blinking cursors.
  • Event handling: Listening and responding to keyboard and mouse events.
  • Screen handling: Switching from main to alternate screens and clearing the screen.
  • Raw mode: Switching a terminal to raw mode.

In this article, we will use a combination of the Rust standard library and the Termion crate to develop a terminal-oriented application. Let’s begin by looking at the Termion crate.

The Termion crate

Why use an external crate for terminal management?

While it is technically possible to work at the byte level using the Rust standard library, it is cumbersome. External crates such as Termion help us group individual bytes to keypresses, and also implement many of the commonly used terminal management functions, which allows us to focus on the higher level, user-directed functionality.

Let’s discuss a few terminal management features of the Termion crate. The official documentation of the crate can be found at https://docs.rs/termion/.

The Termion crate has the following key modules:

  • cursor: For moving cursors
  • event: For handling key and mouse events
  • raw: To switch the terminal to raw mode
  • style: To set various styles on text
  • clear: To clear the entire screen or individual lines
  • color: To set various colors to text
  • input: To handle advanced user input
  • scroll: To scroll across the screen

To include the Termion crate, start a new project and add the following entry to cargo.toml:

[dependencies]
termion = "1.5.5"

A few examples of Termion usage are shown through code snippets here:

  • To get the terminal size, use the following:
termion::terminal_size()
  • To set the foreground color, use the following:
println!(“{}”, color::Fg(color::Blue));
  • To set the background color and then reset the background color to the original state, use the following:
println!(
“{}Background{} “,
color::Bg(color::Cyan),
color::Bg(color::Reset)
);
  • To set bold style, use the following:
println!(
“{}You can see me in bold?”,
style::Bold
);
  • To set the cursor to a particular position, use the following:
termion::cursor::Goto(5, 10)
  • To clear the screen, use the following:
print!(“{}”, termion::clear::All);

We will use these terminal management features in a practical example in the upcoming sections. Let’s now define what we are going to build.

What will we build?

Figure 1 shows the screen layout of what we will build:

Figure 1 - Text viewer screen layout

There are three components in the terminal interface of the text viewer:

  • Header bar: This contains the title of the text editor.
  • Text area: This contains the lines of text to be displayed.
  • Footer bar: This displays the position of the cursor, the number of lines of text in the file, and the name of the file being displayed.

The text viewer will allow the user to perform the following actions:

  • Users can provide a filename as a command-line argument to display. This should be a valid filename that already exists. If the file does not exist, the program will display an error message and exit.
  • The text viewer will load the file contents and display them on the terminal. If the number of lines in a file is more than the terminal height, the program will allow the user to scroll through the document, and repaint the next set of lines.
  • Users can use the up, down, left, and right keys to scroll through the terminal.
  • Users can press Ctrl + Q to exit the text viewer.

A popular text viewer would have a lot more features, but this core scope provides an adequate opportunity for us to learn about developing a terminal-oriented application in Rust.

In this section, we’ve learned what terminals are and what kinds of features they support. We also saw an overview of how to work with the Termion crate and defined what we will be building as part of the project. In the next section, we’ll develop the first iteration of the text viewer.

Working with the terminal UI (size, color, styles) and cursors

The code in this section is organized as follows:

  • Writing data structures and the main() function
  • Initializing the text viewer and getting the terminal size
  • Displaying a document and styling the terminal color, styles, and cursor position
  • Exiting the text viewer

Let's start with data structures and the main() function of the text viewer.

Writing data structures and the main() function

1. Create a new project and switch to the directory with the following command:

cargo new tui && cd tui

Here, tui stands for terminal user interface. Create a new file called textviewer1.rs under src/bin.

2. Add the following to cargo.toml:

[dependencies]
termion = “1.5.5”

3. Let’s first import the required modules from the standard library and the Termion crate:

use std::env::args;
use std::fs;
use std::io::{stdin, stdout, Write};
use termion::event::Key;
use termion::input::TermRead;
use termion::raw::IntoRawMode;
use termion::{color, style};

4. Let’s next define the data structures to represent a text viewer:

struct Doc {
lines: Vec<String>,
}
#[derive(Debug)]
struct Coordinates {
pub x: usize,
pub y: usize,
}
struct TextViewer {
doc: Doc,
doc_length: usize,
cur_pos: Coordinates,
terminal_size: Coordinates,
file_name: String,
}

This code shows three data structures defined for the text viewer:

  • The document that will be displayed in the viewer is defined as a Doc struct, which is a vector of strings.
  • To store cursor position x and y coordinates and to record the current size of the terminal (the total number of rows and columns of characters), we have defined a Coordinates struct.
  • The TextViewer struct is the main data structure representing the text viewer. The number of lines contained in the file being viewed is captured in the doc_length field. The name of the file to be shown in the viewer is recorded in the file_name field.

5. Let’s now define the main() function, which is the entry point for the text viewer application:

fn main() {
//Get arguments from command line
let args: Vec<String> = args().collect();
if args.len() < 2 {
println!(“Please provide file name
as argument”);
std::process::exit(0);
}
//Check if file exists. If not, print error
// message and exit process
if !std::path::Path::new(&args[1]).exists() {
println!(“File does not exist”);
std::process::exit(0);
}
// Open file & load into struct
println!(“{}”, termion::cursor::Show);
// Initialize viewer
let mut viewer = TextViewer::init(&args[1]);
viewer.show_document();
viewer.run();
}

The main() function accepts a filename as a command-line parameter and exits the program if the file does not exist. Furthermore, if a filename is not provided as a command-line parameter, it displays an error message and exits the program.

6. If the file is found, the main() function does the following:

It first calls the init() method on the TextViewer struct to initialize the variables.

Then, it invokes the show_document() method to display the contents of the file on the terminal screen.

Lastly, the run() method is called, which waits for user inputs to the process. If the user presses Ctrl + Q, the program exits.

7. We will now write three method signatures — init(), show_document(), and run(). These three methods should be added to the impl block of the TextViewer struct, as shown:

impl TextViewer {
fn init(file_name: &str) -> Self {
//...
}
fn show_document(&mut self) {
// ...
}
fn run(&mut self) {
// ...
}
}

So far, we’ve defined the data structures and written the main() function with placeholders for the other functions. In the next section, let’s write the function to initialize the text viewer.

Initializing the text viewer and getting the terminal size

Here is the complete code for the init() method:

fn init(file_name: &str) -> Self {
let mut doc_file = Doc { lines: vec![] }; <1>
let file_handle = fs::read_to_string(file_name)
.unwrap(); <2>
for doc_line in file_handle.lines() { <3>
doc_file.lines.push(doc_line.to_string());
}
let mut doc_length = file_handle.lines().count(); <4>
let size = termion::terminal_size().unwrap(); <5>
Self { <6>
doc: doc_file,
cur_pos: Coordinates {
x: 1,
y: doc_length,
},
doc_length: doc_length,
terminal_size: Coordinates {
x: size.0 as usize,
y: size.1 as usize,
},
file_name: file_name.into(),
}
}

The code annotations in the init() method are described here:

  1. Initialize the buffer that is used to store the file contents.
  2. Read the file contents as a string.
  3. Read each line from the file and store it in the Doc buffer.
  4. Initialize the doc_length variable with the number of lines in the file.
  5. Use the termion crate to get the terminal size.
  6. Create a new struct of the TextViewer type and return it from the init() method.

We’ve written the initialization code for the text viewer. Next, we’ll write the code to display the document contents on the terminal screen, and also display the header and footer.

Displaying a document and styling the terminal color, styles, and cursor position

Let’s look at the show_document() method:

src/bin/text-viewer1.rs

fn show_document(&mut self) {
let pos = &self.cur_pos;
let (old_x, old_y) = (pos.x, pos.y);
print!("{}{}", termion::clear::All,
termion::cursor::Goto(1, 1));
println!(
"{}{}Welcome to Super text viewer\r{}",
color::Bg(color::Black),
color::Fg(color::White),
style::Reset
);
for line in 0..self.doc_length {
println!("{}\r", self.doc.lines[line as usize]);
}
println!(
"{}",
termion::cursor::Goto(0, (self.terminal_size.y - 2) as
u16),
);
println!(
"{}{} line-count={} Filename: {}{}",
color::Fg(color::Red),
style::Bold,
self.doc_length,
self.file_name,
style::Reset
);
self.set_pos(old_x, old_y);
}

The code annotations for the show_document() method are described here:

  1. Store the current positions of the cursor x and y coordinates in temp variables. This will be used to restore the cursor position in a later step.
  2. Using the Termion crate, clear the entire screen and move the cursor to row 1 and column 1 on the screen.
  3. Print the header bar of the text viewer. A background color of black and a foreground color of white is used to print text.
  4. Display each line from the internal document buffer to the terminal screen.
  5. Move the cursor to the bottom of the screen (using the terminal size y coordinate) to print the footer.
  6. Print the footer text in red and with bold style. Print the number of lines in the document and filename to the footer.
  7. Reset the cursor to the original position (which was saved to temporary variable in step 1).

Let’s look at the set_pos() helper method used by the show_document() method:

src/bin/text-viewer1.rs

fn set_pos(&mut self, x: usize, y: usize) {
self.cur_pos.x = x;
self.cur_pos.y = y;
println!(
"{}",
termion::cursor::Goto(self.cur_pos.x as u16,
(self.cur_pos.y) as u16)
);
}

This helper method synchronizes the internal cursor tracking field (the cur_pos field of the TextViewer struct) and the on-screen cursor position.

We now have the code to initialize the text viewer and to display the document on the screen. With this, a user can open a document in the text viewer and view its contents. But how does the user exit the text viewer? We’ll find out in the next section.

Exiting the text viewer

To achieve this, we need a way to listen for user key strokes, and when a particular key combination is pressed, we should exit the program. As discussed earlier, we need to get the terminal into raw mode of operation, where each character is available for the program to evaluate, rather than wait for the user to press the Enter key. Once we get the raw characters, the rest of it becomes fairly straightforward. Let’s write the code to do this in the run() method, within the impl TextViewer block, as shown:

src/bin/text-viewer1.rs

fn run(&mut self) {
let mut stdout = stdout().into_raw_mode().unwrap();
let stdin = stdin();
for c in stdin.keys() {
match c.unwrap() {
Key::Ctrl(‘q’) => {
break;
}
_=> {}
}
stdout.flush().unwrap();
}
}

In the code shown, we use the stdin.keys() method to listen for user inputs in a loop. stdout() is used to display text to the terminal. When Ctrl + Q is pressed, the program exits.

We can now run the program with the following:

cargo run --bin text-viewer1 <file-name-with-full-path>

Since we have not implemented scrolling yet, pass a filename to the program that has 24 lines or less of content (this is typically the default height of a standard terminal in terms of the number of rows). You will see the text viewer open up and the header bar, footer bar, and file contents printed to the terminal. Type Ctrl + Q to exit. Note that you have to specify the filename with the full file path as a command-line argument.

In this section, we learned how to get the terminal size, set the foreground and background colors, and apply bold style using the Termion crate. We also learned how to position the cursor onscreen at specified coordinates, and how to clear the screen.

In the next section, we will look at processing keystrokes for user navigation within the document displayed in the text editor and how to implement scrolling.

Processing keyboard inputs and scrolling

In this section, we will add the following features to the text viewer:

  • Provide the ability to display files of any size.
  • Provide the ability for the user to scroll through the document using arrow keys.
  • Add cursor position coordinates to the footer bar.

Let’s begin by creating a new version of the code.

Copy the original code to a new file, as shown:

cp src/bin/text-viewer1.rs src/bin/text-viewer2.rs

This section is organized into three parts. First, we’ll implement the logic to respond to the following keystrokes from a user: up, down, left, right, and backspace. Next, we’ll implement the functionality to update the cursor position in internal data structures, and simultaneously update the cursor position onscreen. Lastly, we’ll allow scrolling through a multi-page document.

We’ll begin with handling user keystrokes.

Listening to keystrokes from the user

src/bin/text-viewer2.rs

fn run(&mut self) {
let mut stdout = stdout().into_raw_mode().unwrap();
let stdin = stdin();
for c in stdin.keys() {
match c.unwrap() {
Key::Ctrl('q') => {
break;
}
Key::Left => {
self.dec_x();
self.show_document();
}
Key::Right => {
self.inc_x();
self.show_document();
}
Key::Up => {
self.dec_y();
self.show_document();
}
Key::Down => {
self.inc_y();
self.show_document();
}
Key::Backspace => {
self.dec_x();
}
_ => {}
}
stdout.flush().unwrap();
}
}

Lines in bold show the changes to the run() method from the earlier version. In this code, we are listening for up, down, left, right, and backspace keys. For any of these keypresses, we are incrementing the x or y coordinate appropriately using one of the following methods: inc_x(), inc_y(), dec_x(), or dec_y(). For example, if the right arrow is pressed, the x coordinate of the cursor position is incremented using the inc_x() method, and if the down arrow is pressed, only the y coordinate is incremented using the inc_y() method. The changes to coordinates are recorded in the internal data structure (the cur_pos field of the TextViewer struct). Also, the cursor is repositioned on the screen. All these are achieved by the inc_x(), inc_y(), dec_x(), and dec_y() methods.

After updating the cursor position, the screen is refreshed fully and repainted.

Let’s look at implementing the four methods to update cursor coordinates, and reposition the cursor on the screen.

Positioning the terminal cursor

src/bin/text-viewer2.rs

fn inc_x(&mut self) {
if self.cur_pos.x < self.terminal_size.x {
self.cur_pos.x += 1;
}
println!(
"{}",
termion::cursor::Goto(self.cur_pos.x as u16,
self.cur_pos.y as u16)
);
}
fn dec_x(&mut self) {
if self.cur_pos.x > 1 {
self.cur_pos.x -= 1;
}
println!(
"{}",
termion::cursor::Goto(self.cur_pos.x as u16,
self.cur_pos.y as u16)
);
}
fn inc_y(&mut self) {
if self.cur_pos.y < self.doc_length {
self.cur_pos.y += 1;
}
println!(
"{}",
termion::cursor::Goto(self.cur_pos.x as u16,
self.cur_pos.y as u16)
);
}
fn dec_y(&mut self) {
if self.cur_pos.y > 1 {
self.cur_pos.y -= 1;
}
println!(
"{}",
termion::cursor::Goto(self.cur_pos.x as u16,
self.cur_pos.y as u16)
);
}

The structure of all these four methods is similar and each performs only two steps:

  1. Depending on the keypress, the corresponding coordinate (x or y) is incremented or decremented and recorded in the cur_pos internal variable.
  2. The cursor is repositioned on the screen at the new coordinates.

We now have a mechanism to update the cursor coordinates whenever the user presses the up, down, left, right, or backspace keys. But that’s not enough. The cursor should be repositioned on the screen to the latest cursor coordinates. For this, we will have to update the show_document() method, which we will do in the next section.

Enabling scrolling on the terminal

To display more lines than is possible for the screen size, it is not enough to reposition the cursor. We will have to repaint the screen to fit a portion of the document in the terminal screen depending on the cursor location. Let’s see the modifications needed to the show_document() method to enable scrolling. Look for the following lines of code in the show_document() method:

for line in 0..self.doc_length {
println!("{}\r", self.doc.lines[line as
usize]);
}

Replace the preceding with the following code:

src/bin/text-viewer2.rs

if self.doc_length < self.terminal_size.y {                     <1>       
for line in 0..self.doc_length {
println!("{}\r", self.doc.lines[line as
usize]);
}
} else {
if pos.y <= self.terminal_size.y { <2>
for line in 0..self.terminal_size.y - 3 {
println!("{}\r", self.doc.lines[line as
usize]);
}
} else {
for line in pos.y - (self.terminal_size.y –
3)..pos.y {
println!("{}\r", self.doc.lines[line as
usize]);
}
}
}

The code annotations in the show_document() method snippet are described here:

  1. First, check whether the number of lines in the input document is less than the terminal height. If so, display all lines from the input document on the terminal screen.
  2. If the number of lines in the input document is greater than the terminal height, we have to display the document in parts. Initially, the first set of lines from the document are displayed onscreen corresponding to the number of rows that will fit into the terminal height. For example, if we allocate 21 lines to the text display area, then as long as the cursor is within these lines, the original set of lines is displayed. If the user scrolls down further, then the next set of lines is displayed onscreen.

Let’s run the program with the following:

cargo run –-bin text-viewer2 <file-name-with-full-path>

You can try two kinds of file inputs:

  • A file where the number of lines is less than the terminal height
  • A file where the number of lines is more than the terminal height

You can use the up, down, left, and right arrows to scroll through the document and see the contents. You will also see the current cursor position (both x and y coordinates) displayed on the footer bar. Type Ctrl + Q to exit.

This concludes the text viewer project. You have built a functional text viewer that can display files of any size, and can scroll through its contents using the arrow keys. You can also view the current position of the cursor along with the filename and number of lines in the footer bar.

Note on the text viewer

Note that what we have implemented is a mini version of a text viewer in under 200 lines of code. While it demonstrates the key functionality, additional features and edge cases can be implemented by you to enhance the application and improve its usability. Furthermore, this viewer can also be converted into a full-fledged text editor. These are left to you, the reader, as an exercise.

We’ve completed the implementation of the text viewer project in this section. The text viewer is a classic command-line application, lacking a graphical user interface (GUI) where mouse inputs are needed. But it is important to learn how to handle mouse events, for developing GUI-based terminal interfaces. We’ll learn how to do that in the next section.

Processing mouse inputs

Create a new source file called mouse-events.rs under src/bin.

Here is the code logic:

  1. Import the needed modules.
  2. Enable mouse support in the terminal.
  3. Clear the screen.
  4. Create an iterator over incoming events.
  5. Listen to mouse presses, release and hold events, and display the mouse cursor location on the terminal screen.

The code is explained in snippets corresponding to each of these points.

Let’s first look at module imports:

  1. We’re importing the termion crate modules for switching to raw mode, detecting the cursor position, and listening to mouse events:
use std::io::{self, Write};
use termion::cursor::{self, DetectCursorPos};
use termion::event::*;
use termion::input::{MouseTerminal, TermRead};
use termion::raw::IntoRawMode;

In the main() function, let’s enable mouse support as shown:

fn main() {
let stdin = io::stdin();
let mut stdout = MouseTerminal::from(io::stdout().
into_raw_mode().unwrap());
// ...Other code not shown
}

To ensure that previous text on the terminal screen does not interfere with this program, let’s clear the screen, as shown here:

writeln!(
stdout,
"{}{} Type q to exit.",
termion::clear::All,
termion::cursor::Goto(1, 1)
)
.unwrap();

2. Next, let’s create an iterator over incoming events and listen to mouse events. Display the location of the mouse cursor on the terminal:

for c in stdin.events() {
let evt = c.unwrap();
match evt {
Event::Key(Key::Char('q')) => break,
Event::Mouse(m) => match m {
MouseEvent::Press(_, a, b) |
MouseEvent::Release(a, b) |
MouseEvent::Hold(a, b) => {
write!(stdout, "{}",
cursor::Goto(a, b))
.unwrap();
let (x, y) = stdout.cursor_pos
().unwrap();
write!(
stdout,
"{}{}Cursor is at:
({},{}){}",
cursor::Goto(5, 5),
termion::clear::
UntilNewline,
x,
y,
cursor::Goto(a, b)
)
.unwrap();
}
},
_ => {}
}

stdout.flush().unwrap();
}

In the code shown, we are listening to both keyboard events and mouse events. In keyboard events, we are specifically looking for the Q key, which exits the program. We are also listening to mouse events — press, release, and hold. In this case, we position the cursor at the specified coordinates and also print out the coordinates to the terminal screen.

3. Run the program with the following command:

cargo run --bin mouse-events

4. Click around the screen with the mouse, and you will see the cursor position coordinates displayed on the terminal screen. Press q to exit.

With that, we conclude the section on working with mouse events on the terminal, and also this introduction to terminal I/O management using Rust.

Summary

You’ve learned how to listen to user inputs and track the keyboard arrow keys for scrolling operations, including left, right, up, and down. We wrote code to display document contents dynamically as the user scrolls through it, keeping the constraints of the terminal size in mind. As an exercise, you could refine the text viewer, and also add functionality to convert the text viewer into a full-fledged editor.

Learning about these features is important for writing applications such as terminal-based games, editing and viewing applications and terminal graphical interfaces, and for developing terminal-based dashboards.

To read more about building fast and secure software for Linux/UNIX systems, check out Prabhu Eshwarla’s book Practical System Programming for Rust Developers. Explore Rust features, data structures, libraries, and toolchain to build modern systems software with the help of hands-on examples.

We help developers build better software | Email customercare@packtpub.com for support | Twitter support 9-5 Mon-Fri