Convert "sl" Source Code from C to Rust

Full Code Listing up to this point.
Previous - Get User-Provided Options

Use "ncurses" to Draw the Images

Implement ncurses

At this point, we are able to print one of a the images to the terminal window using "println!()" statements. Let's convert this program to use ncurses instead of "println!()"s, so that we have better control of the text-based graphics. You'll remember tinkering with ncurses earlier, in the Curses "Graphics" for Text-mode Screens lesson. You'll recall that in order to use ncurses we must add it as a third-party crate, to our crate, by telling Cargo to add it to our "Cargo.toml" file, from "https://crates.io", with the following command:

$ cargo add ncurses

Your "Cargo.toml" file will become something like this:

main.rs
[package]
name = "sl"
version = "0.1.0"
edition = "2021"

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

[dependencies]
clap = { version = "4.3.8", features = ["derive"] }
ncurses = "5.101.0"

First, let's get rid of our three test println's:

main.rs
#![allow(unused)]

/* main.rs */

mod convert_to_vec;
mod display;
mod get_options;
mod images;

use convert_to_vec::str_to_vecvecstring;
use display::display_image;
use get_options::get_opts;

fn main() {
    let (model_string, fly, oops, interruptible) = get_opts();
    let model_vecvecstring = str_to_vecvecstring(&model_string);
    display_image(model_vecvecstring);
    println!("The option to fly = {}.", fly); // Temporary test of what we get back in "fly".
    println!("The Accident (\"oops\") option = {}.", oops); // Temporary test of what we get back in "oops".
    println!("The \"interruptible\" option {}.", interruptible); // Temporary test of what we get back in "interruptible".
} // end of main()

You'll also recall that there were several housekeeping functions that we needed to setup. We'll do that in the "main.rs" file, in the "main()" function.

main.rs
/* main.rs */

mod convert_to_vec;
mod display;
mod get_options;
mod images;

use convert_to_vec::str_to_vecvecstring;
use display::display_image;
use get_options::get_opts;
use ncurses::*;

fn main() {
    let (model_string, fly, oops, interruptible) = get_opts();
    let model_vecvecstring = str_to_vecvecstring(&model_string);
 
    // ncurses housekeeping stuff.
    initscr(); // Start ncurses, initializing the screen.
    noecho(); // Turn off keyboard echo to the screen.
    curs_set(CURSOR_VISIBILITY::CURSOR_INVISIBLE); // Turn off the display of the cursor.
    let mut screen_height = 0; // How high...
    let mut screen_width = 0; // ... and wide ...
    getmaxyx(stdscr(), &mut screen_height, &mut screen_width); // ... is the screen?
    
    display_image(model_vecvecstring);
 
    endwin(); // Exit ncurses and restore screen to normal.
} // end of main()

And then convert the "display_image()" function to use ncurses instead of "println!"s. For now, we'll just hard-code the location of the image's "origin" (top left of the image) at, say, 3 rows down and 5 columns in from the left:

display.rs
/* display.rs */

use ncurses::*;
pub fn display_image(image_vecvec: Vec<Vec<String>>) {
    let mut frame_num = 0;
    let mut row = 3;
    let col = 5;
    for each_frame in image_vecvec {                                            
        let msg = format!println!("Frame {}:", frame_num);
        mv(row - 1, col);
        addstr(&msg);
        for each_line in each_frame {
            println!("{}", each_line);
            mv(row, col);
            addstr(&each_line);
            row = row + 1;
        }                                                                       
        frame_num += 1;
        getch(); // This is just temporary.
        row = 3; // Reset the row after each frame is completely drawn.
    }
} // end of display_image()

If you run this without specifying an image (or if you specify the D51 image), what you should see is the first frame of the D51 train, and then the succeeding five frames each time you press a key, only with the wheels running in reverse. We'll fix that reverse issue later. (As a reminder the "getch()" function also accomplishes the equivalent of a "refresh()", which is necessary to display the image after you've "constructed" it behind-the-scenes, and are ready to bring it forward to be visible.) However, if something goes wrong, you might wind up with a screen that is in an inconsistent state, now that we're using ncurses to put the screen in "graphics mode" (sort of). In such a situation, you should be able to fix this with a Ctrl-C, followed by blindly typing "reset" and hitting the enter key.

Move the Choice of Frame and Location to "main()"

Rather than having the "display_image()" function draw all the frames of one image, let's have it draw just one frame of an image, and let the "main()" function determine which frame gets drawn. Let's also let "main()" determine where it gets drawn. And we no longer need to print the frame number. And let's rename the function.

display.rs
/* display.rs */

use ncurses::*;

pub fn display_image(image_vecvec: Vec<Vec<String>>draw_frame(frame: &Vec<String>, mut row: i32, col: i32) {
    let mut frame_num = 0;
    let mut row = 3;
    let col = 5;
    for each_frame in image_vecvec {                                            
        let msg = format!("Frame {}:", frame_num);
        mv(row - 1, col);
        addstr(&msg);
    for each_line in each_frame {
        mv(row, col);
        addstr(&each_line);
        row = row + 1;
    }                                                                       
        frame_num += 1;
        getch(); // This is just temporary.
        row = 3; // Reset the row after each frame is completely drawn.
    }
} // end of display_image()draw_frame()

or, in a cleaner format:

display.rs
/* display.rs */

use ncurses::*;

pub fn draw_frame(frame: &Vec<String>, mut row: i32, col: i32) {
    for each_line in frame {
        mv(row, col);
        addstr(&each_line);
        row = row + 1;
    }
} // end of draw_frame()

And the requisite changes to "main.rs":

main.rs
#![allow(unused)]

/* main.rs */

mod convert_to_vec;
mod display;
mod get_options;
mod images;

use convert_to_vec::str_to_vecvecstring;
use display::display_image;
use display::draw_frame;
use get_options::get_opts;
use ncurses::*;

fn main() {
    let (model_string, fly, oops, interruptible) = get_opts();
    let model_vecvecstring = str_to_vecvecstring(&model_string);

    // ncurses housekeeping stuff.
    initscr(); // Start ncurses, initializing the screen.
    noecho(); // Turn off keyboard echo to the screen.
    curs_set(CURSOR_VISIBILITY::CURSOR_INVISIBLE); // Turn off the display of the cursor.
    let mut screen_height = 0; // How high...
    let mut screen_width = 0; // ... and wide ...
    getmaxyx(stdscr(), &mut screen_height, &mut screen_width); // ... is the screen?

    display_image(model_vecvecstring);

    let row: i32 = 3;
    let col: i32 = 5;
    let frame = &model_vecvecstring[0]; // Get the first frame of the image... 
    draw_frame(frame, row, col); // ... and draw it.
    getch();  // Temporary...

    endwin(); // Exit ncurses and restore screen to normal.
} // end of main()

This should draw one frame of your chosen image, and then wait for a keypress before exiting. By manipulating the "row" and "col" values, we can draw the frame where we want it. If it doesn't fit in your terminal window, you'll get odd results, but we'll fix that later; for now, maybe make your window bigger, or move your (row,col) coordinates a bit to make the image fit on-screen.

Compress Two Commands Into One

This is strictly a stylistic choice, but let's use the alternative ncurses command, "mvaddstr()", to replace the two commands "mv()" and "addstr()". And let's use the "increment shortcut" to add one to the variable "row".

display.rs
pub fn draw_frame(frame: &Vec<String>, mut row: i32, col: i32) {
    for each_line in frame {
        mv(row, col);
        addstr(&each_line);
        mvaddstr(row, col, &each_line);
        row = row + 1;
        row += 1;
    }
} // end of draw_frame()

These two changes are really insignificant; they are just an alternative way of writing the same code.

Move the Frame Across the Screen

Let's move the frame across the screen:

main.rs
...
    let row: i32 = 3;
    let col: i32 = 5;
    for col in (0..screen_width).rev() {
      let frame = &model_vecvecstring[0]; // Get the first frame of the image... 
      draw_frame(row, col, frame); // ... and draw it.
      getch();  // Temporary...
    }
    endwin(); // Exit ncurses and restore screen to normal.
} // end of main()
...

Yay! With each press of a key, the image moves across the screen.

But there are several problems:

We can fix these things.

Replace the Keypress with a Delay

Rather than pressing a key to advance the image across the screen, let's replace that "getch()" with a sleep/delay between frames, lasting a few milliseconds. Since we're removing the "getch()", which doubles as a "refresh()", we'll have to put a "refresh()" in between the frames. We can do this in "main()", after each frame is drawn, or we can do it in the "draw_frame()" function. Since the frame is not really drawn until it is displayed on the screen, it seems that in order to have the "draw_frame()" function do the entire job, the "refresh()" statement belongs there, rather than in "main()". So that's where we'll put it.

main.rs
#![allow(unused)]

/* main.rs */

mod convert_to_vec;
mod display;
mod get_options;
mod images;

use convert_to_vec::str_to_vecvecstring;
use display::draw_frame;
use get_options::get_opts;
use ncurses::*;
use std::{thread, time}; // For a sleep/delay between image frames.

fn main() {
    let (model_string, fly, oops, interruptible) = get_opts();
    let model_vecvecstring = str_to_vecvecstring(&model_string);

    // ncurses housekeeping stuff.
    initscr(); // Start ncurses, initializing the screen.
    noecho(); // Turn off keyboard echo to the screen.
    curs_set(CURSOR_VISIBILITY::CURSOR_INVISIBLE); // Turn off the display of the cursor.
    let mut screen_height = 0; // How high...
    let mut screen_width = 0; // ... and wide ...
    getmaxyx(stdscr(), &mut screen_height, &mut screen_width); // ... is the screen?

    // This will be our delay, in milliseconds, between frame images.
    let delay = 100;
    let delay_between_frames = time::Duration::from_millis(delay);

    let row: i32 = 3;
    for col in (0..screen_width).rev() {
        let frame = &model_vecvecstring[0]; // Get the first frame of the image... 
        draw_frame(frame, row, col); // ... and draw it.
        getch();  // Temporary...
        thread::sleep(delay_between_frames);
    }
display.rs
/* display.rs */

use ncurses::*;

pub fn draw_frame(frame: &Vec<String>, mut row: i32, col: i32) {
    for each_line in frame {
        mvaddstr(row, col, &each_line);
        row += 1;
    }
    refresh();
} // end of draw_frame()

Now when you run the program, you no longer have to press a key to advance the image across the screen. You can adjust the speed at which the image crosses the screen by changing the value of the "delay" variable. A bigger number will create more of a delay, making the image move more slowly. Eventually, we'll give the user the ability to control the speed from the command-line.

We can also write our code in "main()" this way (which I prefer, as it makes the code a little easier to read):

main.rs
...
use std::{thread::sleep, time::Duration}; // For a sleep/delay between image frames.
...
    let delay_between_frames = time::Duration::from_millis(delay);
...
thread::sleep(delay_between_frames);
...

This is one of those rare cases where using a "magic number" would actually make the program more readable:

    let delay_between_frames = Duration::from_millis(100);

actually conveys more information than does:

    let delay_between_frames = Duration::from_millis(delay);

but, if we want to later be able to vary the animation speed according to a command-line option, we need to use a variable.

Move the Image to the Bottom of the Screen

Let's draw the image so that its bottom is at the bottom of the terminal window. In order to do that, we need to know how tall the image is. The author of the C-version of "sl" solved this problem by using "#define"s in his "sl.h" file that record the number of lines in an image. For example, there's a "#define" for the D51's height, named "D51HIGHT", set to 10, since there are ten lines in that image. Rather than having the artist of an image count the images and provide that in our image definition, we can just let our program count the number of lines. Since each of the inner vectors has one element per line, if we count the number of elements in one of the vectors, we know how many lines tall the image is:

main.rs
...
    // This will be our delay, in milliseconds, between frame images.
    let delay = 100;
    let delay_between_frames = Duration::from_millis(delay);

    // How tall is the image? (How many lines are in the first inner vector?)
    let image_height = model_vecvecstring[0].len() as i32; // Normally type usize, but we need i32 for the "let row..." below.

    let row: i32 = screen_height - image_height;
    let row: i32 = 3;
    for col in (0..screen_width).rev() {
...

Erase the Unwanted Trail

Each time a frame is displayed on the screen, it overwrites most of the drawing of the previous frame, except for the last column of the previous frame. If the last column of a particular line is empty, no problem; we're not overwriting an empty space. But if that last column is non-empty, then it gets left behind on the screen. We could erase the whole screen between frames, but that's overkill, eating resources that we don't need to eat, and potentially having unwanted side-effects (imagine if we had other drawings on the screen; they'd get erased too). Another way to erase that last column would be to make sure each drawing has an empty last column. We could put that burden on the ASCII-artist, so that an image must be stored in "images.rs" like this:

pub const BOAT: &str = r"
   |\      '
   | \     '
   |  \    '
   |___\   '
\--|----/  '
 \_____/   '
"; // end of BOAT

instead of like this:

pub const BOAT: &str = r"
   |\     '
   | \    '
   |  \   '
   |___\  '
\--|----/ '
 \_____/  '
"; // end of BOAT

Another way would be to add a space character to the end of each line of each frame. We'll do it that way. We'll use the format! macro, which is just like the println! macro, except that instead of printing the result, it returns the result as a String.

convert_to_vec.rs
pub fn str_to_vecvecstring(incoming_str: &str) -> Vec<Vec<String>> {
...
       } else { // The line is not blank, so process it as part of the current frame.
            if &each_line[each_line.len() - 1..] == "'" { // If end of line is a single-quote ...
                each_line = &each_line[0..each_line.len() - 1]; // ... remove it.
            }
            // Add an erasing space to the end of each line (converting it to a String from a &str).
            let each_line = format!("{} ", each_line);
            // Now add the non-blank line to the current inner vector.
            outer_vec[inner_vec_num].push(each_line.to_string());
        } // Then move on to the next loop/line.
...

An alternative way to do this is:

convert_to_vec.rs
pub fn str_to_vecvecstring(incoming_str: &str) -> Vec<Vec<String>> {
...
       } else { // The line is not blank, so process it as part of the current frame.
            if &each_line[each_line.len() - 1..] == "'" { // If end of line is a single-quote ...
                each_line = &each_line[0..each_line.len() - 1]; // ... remove it.
            }
            // Add an erasing space to the end of each line.
            let mut line = each_line.to_string(); // Convert 'each_line' to a String.
            line.push(' ');
            // Now add the non-blank line to the current inner vector.
            outer_vec[inner_vec_num].push(each_line.to_string());
        } // Then move on to the next loop/line.
...

Now you should be able to opt for the D51 and C51 trains without them leaving trails.

Animate the Image

Right now we're just moving a single frame across the screen. What we want to do instead is to "animate" the image by alternating between the available frames as we go. To do this, we need to know the number of frames in an image, and the total trip distance.

The total distance the train must travel is the width of the screen plus the length of the train. See the image below to envision that distance.

Distance train must travel.

Now we need to divide the total trip distance by the number of frames, and look at the remainder of that operation. In the case of an image that has six frames, such a mathematical operation will result in a remainder of 0 to 5; with an image that has four frames, the op will produce a remainder of 0 to 3; and so on. So each time we move one column left on the screen, we'll do this math, and the remainder will tell us which frame of the image to display.

main.rs
...
fn main() {
    // Get image data; put it in "model_vecvecstring".
    let (model_string, fly, oops, interruptible) = get_opts();
    let model_vecvecstring = str_to_vecvecstring(&model_string);
...

    // How many frames in the image?
    let num_of_frames = model_vecvecstring.len() as i32;

    // How tall is the image? (How many lines are in the first inner vector?)
    let image_height = image_vecvec[0].len() as i32;
    
    // How wide is the image (in characters/screen columns)?
    let image_width = model_vecvecstring[0][0].len() as i32;

    let row: i32 = screen_height - image_height;
    for col in (0-image_width..screen_width).rev() { // Distance to travel is width of screen plus the width of the image.
        let frame_num = (col.abs() % num_of_frames) as usize; // The remainder of this math tells us which frame to display, in descending order.
        let frame = &model_vecvec[0frame_num]; // For a 6-frame image, the result will be somewhere in the range of 0 to 5.
        draw_frame(frame, row, col); // ... and draw it.
        sleep(delay_between_frames);
    }
...

This animation almost works, except for three issues, two of which we've already seen:

Fix the C51

I made some tweaks to the C51 which solves the wheels running in reverse, and, I believe, improves the animation slightly. Just replace your existing C51 constant with this one:

images.rs
...
pub const C51: &str = r"
        ___                                            '
       _|_|_  _     __       __             ___________'
    D__/   \_(_)___|  |__H__|  |_____I_Ii_()|_________|'
     | `---'   |:: `--'  H  `--'         |  |___ ___|  '
    +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_||  '
    ||        | ::       H  +=====+      |  |::  ...|  '
|    | _______|_::-----------------[][]-----|       |  '
| /~~ ||   |-----/~~~~\  /[I_____I][][] --|||_______|__'
------'|oOo|===[]-     ||      ||      |  ||=======_|__'
/~\____|___|/~\_|    O=======O=======O |__|+-/~\_|     '
\_/         \_/  \____/  \____/  \____/      \_/       '

        ___                                            '
       _|_|_  _     __       __             ___________'
    D__/   \_(_)___|  |__H__|  |_____I_Ii_()|_________|'
     | `---'   |:: `--'  H  `--'         |  |___ ___|  '
    +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_||  '
    ||        | ::       H  +=====+      |  |::  ...|  '
|    | _______|_::-----------------[][]-----|       |  '
| /~~ ||   |-----/~~~~\  /[I_____I][][] --|||_______|__'
------'|oOo|==[]=-     ||      ||      |  ||=======_|__'
/~\____|___|/~\_|      ||      ||      |__|+-/~\_|     '
\_/         \_/  \_O=======O=======O__/      \_/       '

        ___                                            '
       _|_|_  _     __       __             ___________'
    D__/   \_(_)___|  |__H__|  |_____I_Ii_()|_________|'
     | `---'   |:: `--'  H  `--'         |  |___ ___|  '
    +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_||  '
    ||        | ::       H  +=====+      |  |::  ...|  '
|    | _______|_::-----------------[][]-----|       |  '
| /~~ ||   |-----/~~~~\  /[I_____I][][] --|||_______|__'
------'|oOo|=[]==-     ||      ||      |  ||=======_|__'
/~\____|___|/~\_|      ||      ||      |__|+-/~\_|     '
\_/         \_/  O=======O=======O____/      \_/       '

        ___                                            '
       _|_|_  _     __       __             ___________'
    D__/   \_(_)___|  |__H__|  |_____I_Ii_()|_________|'
     | `---'   |:: `--'  H  `--'         |  |___ ___|  '
    +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_||  '
    ||        | ::       H  +=====+      |  |::  ...|  '
|    | _______|_::-----------------[][]-----|       |  '
| /~~ ||   |-----/~~~~\  /[I_____I][][] --|||_______|__'
------'|oOo|[]===-     ||      ||      |  ||=======_|__'
/~\____|___|/~\_|O=======O=======O     |__|+-/~\_|     '
\_/         \_/  \____/  \____/  \____/      \_/       '

        ___                                            '
       _|_|_  _     __       __             ___________'
    D__/   \_(_)___|  |__H__|  |_____I_Ii_()|_________|'
     | `---'   |:: `--'  H  `--'         |  |___ ___|  '
    +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_||  '
    ||        | ::       H  +=====+      |  |::  ...|  '
|    | _______|_::-----------------[][]-----|       |  '
| /~~ ||   |-----/~~~~\  /[I_____I][][] --|||_______|__'
------'|oOo|=[]==-O=======O=======O    |  ||=======_|__'
/~\____|___|/~\_|      ||      ||      |__|+-/~\_|     '
\_/         \_/  \____/  \____/  \____/      \_/       '

        ___                                            '
       _|_|_  _     __       __             ___________'
    D__/   \_(_)___|  |__H__|  |_____I_Ii_()|_________|'
     | `---'   |:: `--'  H  `--'         |  |___ ___|  '
    +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_||  '
    ||        | ::       H  +=====+      |  |::  ...|  '
|    | _______|_::-----------------[][]-----|       |  '
| /~~ ||   |-----/~~~~\  /[I_____I][][] --|||_______|__'
------'|oOo|==[]=-   O=======O=======O |  ||=======_|__'
/~\____|___|/~\_|      ||      ||      |__|+-/~\_|     '
\_/         \_/  \____/  \____/  \____/      \_/       '
"; // end of C51
...

Trim Off the Right Edge of Image that is Beyond the Right Edge of Screen

In order to prevent the right-end of the image from wrapping around to the left edge of the screen, we need to trim off any part of the image that does not fit on the right edge of the screen. The "mvaddstr()" routine that is part of ncurses does not do that, but we can write our own version that does. So in the next lesson, we'll just Create Our Own my_mvaddstr() Function, which checks the bounds of the screen, and trims off any part of the string that doesn't fit on the screen.