Implementing Terminal I/O in Rust

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

Technical requirements

Introducing terminal I/O fundamentals

Characteristics of terminals

  • 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.
  • 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.

The Termion crate

  • 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
[dependencies]
termion = "1.5.5"
  • 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);

What will we build?

Figure 1 - Text viewer screen layout
  • 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.
  • 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.

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

  • 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

Writing data structures and the main() function

cargo new tui && cd tui
[dependencies]
termion = “1.5.5”
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};
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,
}
  • 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.
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();
}
impl TextViewer {
fn init(file_name: &str) -> Self {
//...
}
fn show_document(&mut self) {
// ...
}
fn run(&mut self) {
// ...
}
}

Initializing the text viewer and getting the terminal size

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(),
}
}
  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.

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

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);
}
  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).
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)
);
}

Exiting the text viewer

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();
}
}
cargo run --bin text-viewer1 <file-name-with-full-path>

Processing keyboard inputs and scrolling

  • 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.
cp src/bin/text-viewer1.rs src/bin/text-viewer2.rs

Listening to keystrokes from the user

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();
}
}

Positioning the terminal cursor

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)
);
}
  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.

Enabling scrolling on the terminal

for line in 0..self.doc_length {
println!("{}\r", self.doc.lines[line as
usize]);
}
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]);
}
}
}
  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.
cargo run –-bin text-viewer2 <file-name-with-full-path>
  • 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

Processing mouse inputs

  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.
  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;
fn main() {
let stdin = io::stdin();
let mut stdout = MouseTerminal::from(io::stdout().
into_raw_mode().unwrap());
// ...Other code not shown
}
writeln!(
stdout,
"{}{} Type q to exit.",
termion::clear::All,
termion::cursor::Goto(1, 1)
)
.unwrap();
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();
}
cargo run --bin mouse-events

Summary

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Packt

Packt

3.4K Followers

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