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

Curses! Drawing to the Terminal Screen

Curses?

As you've surely realized by now, "graphics" in a text-based terminal or terminal window are different than graphics in a Graphical User Interface (GUI) like the X11 Window System or Wayland or Microsoft Windows or macOS. These Terminal User Interface (TUI) graphics are blocky, being composed of ASCII characters (or UTF-8 characters, in more modern systems), as opposed to the individual pixels used for graphics in a GUI environment.

When doing "graphical" work on a text-based terminal screen, there are certain capabilities that are needed, like erasing the screen, or restoring what was just erased, or moving the curser to this place or that place on the screen, etc. A popular library of utilities to handle this sort of thing in the C programming language is called "curses", or "ncurses" for a newer version. The C-version of our "sl.c" program uses "curses". If you look at the "sl.c" program, you'll see such references as these:

~/projects/C/sl/sl-5.02$ less sl.c

#include <curses.h>
...
initscr();
noecho();
curs_set(0);
nodelay(stdscr, TRUE);
leaveok(stdscr, TRUE);
scrollok(stdscr, FALSE);

These are all items related to the "curses" library. The #include <curses.h> command above brings into view (or "scope") the "curses" library from a third-party source.

Although some of those functions can be pretty easily implemented without the "curses" library, using ANSI ESCape Sequences, as we did in the last lesson, there are some limitations using that method that are hard to overcome. It's easier to turn to a pre-built library of functions that have already solved these problems for us. That's where "[n]curses" comes in. Thankfully, someone has ported an ncurses library to Rust.

Yes, Curses!

There's a Rust-maintained web site named "crates.io" that is a repository of Rust-related packages (called "crates"). If you web-browse to that site, and search for "ncurses", you'll find a version of it, at this time of writing version 5.101.0. When you click into that link, you'll see it referred to as "ncurses-rs" (for "ncurses for RuSt", presumably - there's also available an older version of "curses", that does not mention "ncurses-rs"; you don't want that older version). On this "ncurses-rs" page, you'll see on the right-hand side of the page that to install this, you add a line to your "Cargo.toml" file. This file was mentioned earlier. It's kind of a configuration file for Cargo, and is created by Cargo for every new Cargo project when you run "cargo new ...".

Since we're now experimenting with curses instead of with ANSI, let's start a new project. Move into the "~/projects/RUST" directory and run:

$ cargo new curses_experiment

Now take a look at the just-created "Cargo.toml" file. Here's mine before any changes have been made to it:

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

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

[dependencies]

Not much to it.

To add the ncurses 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 ncurses

If you wanted a specific version of the ncurses "crate" (or package), you could type something like this:

$ cargo add ncurses@5.101.0

(If you're using an older version of Rust/Cargo that does not yet have this feature, you can edit the "Cargo.toml" file manually to add the required line.)

The resulting "Cargo.toml" should look like this (the highlight shows what was added):

[package]
name = "curses_experiment"
version = "0.1.0"
edition = "2021"

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

[dependencies]
ncurses = "5.101.0"

Now you have told Cargo that "ncurses" of a certain version number is needed by this "curses_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/curses_experiment/$ cargo run
    Updating crates.io index
   Compiling cc v1.0.77
   Compiling libc v0.2.138
   Compiling pkg-config v0.3.26
   Compiling ncurses v5.101.0
   Compiling sl v0.1.0 (/home/kent/projects/rust/curses_experiment)
    Finished dev [unoptimized + debuginfo] target(s) in 2.71s
     Running `target/debug/curses_experiment`
Hello, world!
~/projects/RUST/curses_experiment$

In ye olde days of Rust programming, we would have needed to declare that this program depends on the third-party crate "ncurses" by adding at the top of the file a line like extern crate ncurses;, but nowadays we declare dependencies in the "Cargo.toml" file, as we did above.

Although we've told Cargo about the "ncurses" crate, we have not yet told "main.rs" about it. So edit your "src/main.rs" file to this:

use ncurses;
  
fn main() {
    println!("Hello, world!");
} // end of main()

If we cargo run this, we won't see any difference yet, other than a warning than ncurses is an unused import. What we've done is we've told "main.rs" that we're going to be accessing the "ncurses" library, and we've told Cargo, via the "Cargo.toml" file, where to get that library.

If instead of a successful compilation/run (with the aforementioned warning), you get an error like this:

fatal error: ncurses.h: No such file or directory

that just means you don't have the ncurses library for program-development installed in your Debian computer. That's easy to fix:

$ sudo apt install libncurses-dev

Now let's actually do something with the "ncurses" crate. Edit your "main.rs" file to this:

use ncurses;

fn main() {
  println!("Hello, world!");
  
  /* Start ncurses. */
  ncurses::initscr();
 
  /* Print to the back buffer. */
  ncurses::addstr("Hello, world!");
 
  /* Update the screen. */
  ncurses::refresh();
 
  /* Wait for a key press. */
  ncurses::getch();
 
  /* Terminate ncurses. */
  ncurses::endwin();
} // end of main()

NOTE: If your program has a bug in it, ncurses may leave your screen in an inconsistent state. Most of the time, a Ctrl-C, followed by a blind-typing of the command "reset", will restore your screen to normal. If not, you may just have to close your terminal and open a new one.

When you compile/run this (cargo run), your terminal window will erase, then print "Hello, world!", then wait for you to press a key. Once you press a key, the screen will return back to the way it was before you ran the program, and then the program will quit.

There are several steps here. The "ncurses::initscr()" statement initializes the "graphics" mode of the text-based terminal window. Among other things that it does, it takes a "snapshot" of the pre-run screen, and then erases the screen. Its corresponding statement at the end of the program, "ncurses::endwin()", returns the screen back to its non-"graphics" mode, and restores the screen back to the snapshot it took. Then the program quits.

The "ncurses::addstr("Hello, world!")" statement prints the "Hello, world!" message, or more accurately, adds the string ("addstr") "Hello, world!", to the screen.

Sort of. Not really.

Imagine a theater stage, with a great curtain hiding the stage from the audience. While the curtain is down, the stage-hands can set up the scene, bringing in sets, placing props, etc. They're "staging" the scene, but the scene is not yet visible to the audience.

That's the way "ncurses" works; you "stage" the screen the way you want it, and once you have it the way you want it, you raise the curtain. That's what the "ncurses::refresh()" statement does. It "raises the curtain" to reveal the "back buffer" stage area.

The "ncurses::getch()" statement is designed to get a character that is typed on the keyboard. In this case, we don't care what the character is that we get; we just want the program to wait until some key is pressed on the keyboard. Once that happens, the "ncurses::endwin" statement runs and the program finishes out and quits.

Also note that each of the functions that we use that comes from the "ncurses" crate has to be supplied with the path to that function; the Rust system has no idea about this foreign function named "addstr()", or where to find it, so we have to provide the path to that function. "ncurses::addstr()" means find the external crate named "ncurses" that we named at the beginning of this file, and look in it for the "addstr()" function." Strictly speaking, since we provide the path in each statement, we don't even need the "use ncurses;" line at the beginning of the program. It actually is useless as long as we provide the path each time it's needed.

But it gets really tedious to type "ncurses::" everytime we use an "ncurses" function.

This is where the "use ncurses;" line comes in; with a little modification, it functions as a shortcut to save us a lot of typing. At the beginning of the file, just before "fn main", we'll add/modify the "use ncurses;" statement to be "use ncurses::*;", like below, and that will tell the program to "use" all (that's the asterisk (*) part) of the functions it can find in the "ncurses::" path. When the program comes across a function it doesn't know, it now knows to look in this location of "ncurses::", and if it finds a matching function name, to "use" it.

The resulting "src/main.rs" program now looks like this:

use ncurses::*;

fn main() {

  /* Start ncurses. */
  ncurses::initscr();
 
  /* Print to the back buffer. */
  ncurses::addstr("Hello, world!");
 
  /* Update the screen. */
  ncurses::refresh();
 
  /* Wait for a key press. */
  ncurses::getch();
 
  /* Terminate ncurses. */
  ncurses::endwin();
} // end of main()

Notice that in addition to adding the "use" statement, we deleted all the "ncurses::" path leaders from the ncurses-provided functions. If you're curious, it would not break anything to have left those path leaders in the program, but if the "use" statement is not taken advantage of somewhere in the program, the compiler will complain (as a warning, not as a show-stopper) that the "use" statement is unused.

Now let's change the "addstr()" statement to a "mvaddstr()" statement. This statement will move the cursor to a certain set of (Y,X) coordinates on the screen (with Y being rows from the top, and X being columns from the left)., and then it adds the string to the screen. For example, if we want the "H" of "Hello, world!" to start at the 10th row down from the top of the terminal window, and 20 columns in from the left-hand side of the terminal window, we'd change our statement to this:

  /* Print to the back buffer. */
  addstr("Hello, world!");
  mvaddstr(10, 20, "Hello, world!");

Give it a try (cargo run)!

The above statement can be broken into two statements if you prefer. The above statement of mvaddstr(10, 20, "Hello, world!"); is equivalent to this:

mv(10, 20);
addstr("Hello, world!");

Suppose you wanted to add another message at row 20, column 3, after you press the key? Try this:

fn main()
{
  /* Start ncurses. */
  initscr();
 
  /* Print to the back buffer. */
  mvaddstr(10, 20, "Hello, world!");
 
  /* Update the screen. */
  refresh();
 
  /* Wait for a key press. */
  getch();
  
  /* Print another message. */
  mvaddstr(20,3,"Greetings, Earth!");
  
  // Update the screen again.
  refresh();
  
  /* Wait for another key press. */
  getch();
 
  /* Terminate ncurses. */
  endwin();
} // end of main()

(I didn't include the "use..." line above the "main" function, to save typing/space, but that line is still needed.)

Technically, the "refresh()" statements are not needed here, because the "getch()" statements trigger their own refresh of the screen; but you probably don't want to rely on your memory of which statements trigger a refresh or not, nor do you want your readers who might be trying to understand your code to rely on this bit of knowledge; it may be better to put the "refresh()" in, even if it's redundant in some cases.

Notice also that on the "Wait for another key press." line I used the double-slash (//) style of comment, instead of the multiline (/* */) style. I only did this to point out that you can use either style for making comments; the advantage of the multiline comment is that you can easily make several lines into a comment without having to add double-slashes to the beginning of each line.

Finally note that I did not put spaces after the commas in the second "mvaddstr()" command (mvaddstr(20, 3, "Gree... vs mvaddstr(20,3,"Gree...). As a general rule, Rust does not require spaces after commas in argument lists, but they can help make the code easier to read. Some coders prefer the space-saving of not using the extra white-space; it's a personal preference.

Run the program again, and on the first keypress, press the letter "k"; you'll see "k" appear at the end of the "Hello, world!" line. That's what "getch()" does; it gets a character from the keyboard, and echoes it to the current location of the cursor onto the screen. That's not something we really want happening in our program. Fortunately, there's an easy way to fix it with an ncurses utility; just add "noecho();" somewhere early in the program, like so:

  ...
  
  /* Start ncurses. */
  initscr();
 
  /* Don't echo keypresses to the screen. */
  noecho();
  
  /* Print to the back buffer. */
  mvaddstr(10, 20, "Hello, world!");

  ...

Try your program now, and you'll no longer see the letter "k" appear when you press it.

Notice also that there's a cursor (a "█" in my terminal) at the end of our texts; we can turn that off with the following:

...
  
  /* Start ncurses. */
  initscr();
 
  /* Don't echo keypresses to the screen. */
  noecho();
  
  /* Turn off display of cursor. */
  curs_set(CURSOR_VISIBILITY::CURSOR_INVISIBLE);
  
...

Even though Rust handles strings and characters using the UTF-8 text format, which is backward-compatible with ASCII, but also provides things like Japanese characters, or box-drawing symbols, etc, curses can only handle the ASCII-character subset of UTF-8. If curses could handle UTF-8 fully, then we could have drawn a tiny airplane ("✈") or other such interesting characters. curses is pretty bare-metal, and other libraries can handle UTF-8 characters, but since "sl.c" uses curses, that's what we'll use.

Using a loop, we can simulate movement. Clean out your "main.rs" file, and make it look like the following:

use ncurses::*;

fn main() {   
  /*
    Start ncurses, initializing the screen. Turn off keyboard
    echo to screen. And turn off display of cursor.   
 */
  initscr();                                
  noecho();                              
  curs_set(CURSOR_VISIBILITY::CURSOR_INVISIBLE);


  // Move an "*" a few columns across the screen.
  for column in 5..50 {
    mv(10, column);
    addstr(" *");
    refresh();
    // We need a delay here or the action will happen too fast to see. For now, we'll just advance manually with a getch().
    getch();
  }
 
 
  // Wait for a key press.   
  mv(1,1);
  addstr("Press a key to end program...");
  refresh();    // Technically optional since "getch()" will do a refresh.
  getch();

    
  // Terminate ncurses, restore screen.  
  endwin();

} // end of main()

This program loops through the "for..." section of code 45 times, counting each loop, from 5 to 50. A loop variable is created named "column", which has the value of 5 on the first time through, then 6 the next time, then 7, and so on until it gets to 50. On the first loop, it moves the cursor to the 10th row down, and the 5th column in (because the value of the variable "column" during this first loop is 5), where it then prints " *". It then refreshes the screen to "raise the curtain" so we can see the results.

Then the program waits for you to press a key (without telling you that), and when you do so, then the process repeats, but starting at the 6th column in (because now "column" has the value of 6). The extra space that we're printing "erases" the previous "*" by replacing the "*" with the space in " *".

And then the process repeats again, at the seventh column in, and keeps repeating until we get to the 50th column, at which time the program "falls" out of the loop and continues on to the "Wait for a key press" section.

If we didn't have the "getch()" in place, the process would happen so fast that our eyes can't see anything but the final result when the asterisk stops moving. You can try it by removing that "getch()" line. Rather than having a manual keypress to slow things down, we need to slow down the process by putting a delay in the loop. Rust has a mechanism that can help us do that. It's not as easy in Rust as in some other languages, but we can do it.

...    
    // We need a delay here or the action will happen too fast to see. For now, we'll just advance manually with a getch().
    getch();
    let ten_milliseconds = std::time::Duration::from_millis(10);
    std::thread::sleep(ten_milliseconds);
...

From the standard library ("std"), the "time" section, the "Duration" subsection, we're using a "from_millis()" routine to build a special type of variable required by the "sleep()" routine, setting it equal to 10 milliseconds, and naming it "ten_milliseconds".

Then from the standard library, the "thread" section, we're using the "sleep()" routine to sleep the thread (the program we're running), using that special variable we just built, so the thread (our currently running program) "sleeps" for ten milliseconds.

If you compile/run the program now, you'll be able to see the asterisk move, probably, although it's still pretty fast. Let's use a more generic variable name, and then change the value we give to the process, to slow it down further. Let's also use a "use" to get rid of those path lead-ins.

...
use ncurses::*;      
use std::{time::Duration, thread};

fn main() {
...
  // We need a delay here or the action will happen too fast to see.
  let ten_milliseconds = std::time::Duration::from_millis(10);
  std::thread::sleep(ten_milliseconds);
  let milliseconds = Duration::from_millis(20);
  thread::sleep(milliseconds);
...

That "use" line of use std::{time::Duration, thread}; is a shortcut way of writing the equivalent two lines, as below:

use std::time::Duration;
use std::thread;

Eventually we may want to be able to change the speed on-the-fly, so let's change the "magic number" 20 into a variable. (The term "magic number" refers to a data item like a number that just appears in the middle of your code without any indication of what it means. Whereas the meaning of this particular magic number is fairly obvious, magic numbers as a general rule are a bad idea).

...
fn main() {

  let delay_in_milliseconds = 20;

  /*
    Start ncurses, initializing the screen. Turn off keyboard
    echo to screen. And turn off display of cursor.   
 */
  initscr();                                
...
  let milliseconds = Duration::from_millis(20delay_in_milliseconds);
  thread::sleep(milliseconds);
...

So now we have a variable we can change whenever we want to change the speed of the asterisk. But what if we wanted to change the speed during the computer run, like if we wanted the asterisk to make a return trip at a faster or slower speed? Here's some code that makes the asterisk go one way, then the other. Both trips should take the same time.

...
  // Move an "*" a few columns across the screen.
  for column in 5..50 {
    mv(10, column);
    addstr(" *");
    refresh();
    // We need a delay here or the action will happen too fast to see
    let milliseconds = Duration::from_millis(delay_in_milliseconds);
    thread::sleep(milliseconds);
  }

  // Now go the other direction.
  for column in (5..50).rev() {
    mv(10, column);
    addstr("* ");
    refresh();
    // We need a delay here or the action will happen too fast to see
    let milliseconds = Duration::from_millis(delay_in_milliseconds);
    thread::sleep(milliseconds);
  }
  ...

The ".rev()" reverses the counting, so it counts 50 to 5 instead of 5 to 50. Also note that we have to move the space to the right-side of the asterisk, to always keep the space on the trail of the asterisk.

If we wanted to change the speed, all we'd have to do is to assign a different number to the variable, right? Like so:

  // Now go the other direction.
  delay_in_milliseconds = 62;
  for column in (5..50).rev() {

Except that doesn't work. We get a compiler error that says "cannot assign twice to immutable variable".

Wha-a-a-ahhh?

In Rust, the default condition of a variable is that it is "immutable", that is, unchangeable. Once it is given a value, that's the only value it can ever be. Which means it's not very variable, huh? It's more ... constant, like a const (which is like a variable, but can never change (just like the default behavior of a variable - huh.)).

Yeah.

However, as much is it seems that a variable and a const are the same thing, they're not. But the differences are kindda technical, and this is not the correct forum for going into that. Suffice it to say that we'll be using a variable, even though by default a variable is "immutable".

So what we want to do is have a variable that is not immutable. That's easy to do. We just change our original declaration by adding the "mut" (for "mutable") keyword, like this:

...
  let mut delay_in_milliseconds = 20;
...

Now the program should compile and run, at different speeds depending on which direction the asterisk is going.

A different way we could have gone is to create an entirely new variable with the same name, like so:

  // Now go the other direction.
  for column in (5..50).rev() {
    let delay_in_milliseconds = 62;
    mv(10, column);
    addstr("* ");
    refresh();
    // We need a delay here or the action will happen too fast to see
    let milliseconds = Duration::from_millis(delay_in_milliseconds);
    thread::sleep(milliseconds);
  }    

With the first method, making "delay_in_milliseconds", mutable, the second loop "inherits" that variable, and is able to change it, and after that loop, the variable still has the new value of 62.

With the second method, the newly-created variable in the second loop is a completely different variable, known only to that loop (because the loop forms a "code block"; any time you see a set of open- and close-curly brackets, think "code block"). This code block has a different "scope" than the code around it, and when it ends, so does the new variable created therein. Although these two variables have the same name, they are indeed two different variables.

You can see this by adding a little bit of test-code:

...
fn main() {
    let mut delay_in_milliseconds = 20;
...
    // Now go the other direction.
    for column in (5..50).rev() {
        delay_in_milliseconds = 62;
        mv(10, column);
        addstr("* ");
        refresh();
        // We need a delay here or the action will happen too fast to see
        let milliseconds = Duration::from_millis(delay_in_milliseconds);
        thread::sleep(milliseconds);
        let msg = format!("In the second loop, delay_in_milliseconds = {}", delay_in_milliseconds);
        mvaddstr(5,0, &msg);
    }

        let msg = format!("AFTER the second loop, delay_in_milliseconds = {}", delay_in_milliseconds);
        mvaddstr(5,0, &msg);

    // Wait for a key press.
...

vs

...
fn main() {
    let mut delay_in_milliseconds = 20;
...
    // Now go the other direction.
    for column in (5..50).rev() {
        let delay_in_milliseconds = 62;
        mv(10, column);
        addstr("* ");
        refresh();
        // We need a delay here or the action will happen too fast to see
        let milliseconds = Duration::from_millis(delay_in_milliseconds);
        thread::sleep(milliseconds);
        let msg = format!("In the second loop, delay_in_milliseconds = {}", delay_in_milliseconds);
        mvaddstr(5,0, &msg);
    }

        let msg = format!("AFTER the second loop, delay_in_milliseconds = {}", delay_in_milliseconds);
        mvaddstr(5,0, &msg);

    // Wait for a key press.
...

Run each of the two versions above, and you'll see that the first version only has one variable, which gets changed in the second loop, and the second version has two variables of the same name, with the second variable only existing temporarily within the second loop.

However, looking at these two loops a bit closer, we can realize that we're assigning a value to the "milliseconds" variable 45 times in each loop; we only need to set it once in each loop. So let's rewrite those two loops a bit. Here are a few things to notice:

...
fn main() {
    let mut delay_in_milliseconds = 20;
...
    /* Move an "*" a few columns across the screen. */
    // How many ms to delay between frames (or "theatrical stage sets").
    let milliseconds =
        Duration::from_millis(
        delay_in_milliseconds);
    // We'll draw our character this many rows down on the screen.
    let row = 10;
    // We're moving this many columns across the screen.
    for column in 0..50 {
        // Move the cursor to the (row,column) coordinates on the screen.
        mv(row, column);
        // "Set the stage" by drawing the character, behind the "lowered stage curtain" (on a hidden screen).
        addstr(" *");
        // "Raise the curtain" / paint the screen, so the user can see our "set stage" (the hidden screen).
        refresh();
        // Pause this long for the "audience" to see the "stage" (the now-revealed screen).
        thread::sleep(milliseconds);
    } // Repeat, using the next number in the loop count, until we're done.

    /* Now go the other direction. */
    // Change speed.
    delay_in_milliseconds = 62;
    let milliseconds =
        Duration::from_millis(
        delay_in_milliseconds
    );
    for column in (0..50).rev() {
        mv(row, column);
        addstr("* ");
        refresh();
        thread::sleep(milliseconds);
    }
...

What happens if you change the 50 to a number larger than the width of our terminal window, like 500? Try it.

Because odd/bad results can occur when we print beyond the bounds of the screen, we should have some code to protect against that. That starts with knowing the size of the screen. ncurses gives us a way to get that value, but it's not particularly intuitive.

  initscr();
  noecho(); 
  curs_set(CURSOR_VISIBILITY::CURSOR_INVISIBLE);
  let mut screen_height = 0;
  let mut screen_width = 0; 
  getmaxyx(stdscr(), &mut screen_height, &mut screen_width);

We create two new mutable variables in which we'll store the screen dimensions. Then we use the "getmaxyx()" command to get the maximum dimensions of the screen.

This command takes three arguments:

When passing a variable to another part of the program, such as to the "getmaxyx()" function, there are three basic ways of doing it:

So now we have the screen-height and screen-width in a couple of variables. Now we can use one of those variables to limit the bounds of travel of our character across the screen.

...
  /*
    Start ncurses, initializing the screen. Turn off keyboard
    echo to screen. And turn off display of cursor.   
 */
  initscr();
  noecho();
  curs_set(CURSOR_VISIBILITY::CURSOR_INVISIBLE);
  let mut screen_height = 0;
  let mut screen_width = 0;
  getmaxyx(stdscr(), &mut screen_height, &mut screen_width);

  // Move an "*" a few columns across the screen.
  let milliseconds =                        // How many ms to delay between frames (or "th>
    Duration::from_millis( 
    delay_in_milliseconds);
  let row = 10;                             // We'll draw our character this many rows dow>
  for column in 0..500screen_width {                        // We're moving this many columns across t>
    mv(row, column);                        // Move the cursor to the (row,column) coordin>
    addstr(" *");                           // "Set the stage" by drawing the character, b>
    refresh();                              // "Raise the curtain" / paint the screen, so >
    thread::sleep(milliseconds);            // Pause this long for the "audience" to see t>
  }                                         // Repeat, using the next number in the loop c>

  // Now go the other direction.
  delay_in_milliseconds = 62;               // Change our delay between frames.
  let milliseconds = Duration::from_millis(delay_in_milliseconds);
  for column in (0..500screen_width).rev() {
    mv(row, column);  
    addstr("* ");  
    refresh();
    thread::sleep(milliseconds);
  }
 

Since we've made so many changes, it might be good to show the entire program again. I have not documented above all the changes below, but you should be able to follow and understand everything.

use ncurses::*;
use std::{thread, time::Duration};

fn main() {
  /* 
    Start ncurses, initializing the screen. Turn
    off keyboard echo to the screen. Turn off
    the display of the cursor. Get the dimensions
    of the terminal window.
  */
  initscr();
  noecho();
  curs_set(CURSOR_VISIBILITY::CURSOR_INVISIBLE);
  let mut screen_height = 0;
  let mut screen_width = 0; 
  getmaxyx(
    stdscr(),
    &mut screen_height,
    &mut screen_width
  );


  /*
     Eventually these will be user-defined variables. 
     For now, the "muts" will generate compiler
     warnings; ignore them.
  */
  let mut delay_in_milliseconds = 40;       // Set the initial delay between animation frames/cels.
  let mut row = 10;                         // Start out drawing this many rows down the screen.


  /* Move a character across the screen. */
  let mut milliseconds =                    // How many ms to delay between frames (or "theatrical stage sets").
    Duration::from_millis(delay_in_milliseconds);
  for column in 1..screen_width {           // Starting at column 1, going all the way across the screen's width.
    mv(row, column);                        // Move the cursor to these (row,column) coordinates on the screen.
    addstr(" *");                           // "Set the stage" by drawing the character, behind the "lowered stage curtain" (on a hidden screen).
    refresh();                              // "Raise the curtain" / paint the screen, so the user can see our "set stage" (the hidden screen).
    thread::sleep(milliseconds);            // Pause this long for the "audience" to see the "stage" (the now-revealed screen).
  }                                         // Repeat, until we're done.


  /* Now move the character in the other direction. */
  delay_in_milliseconds = 82;               // Change our delay between frames, to change the speed of the return trip.
  milliseconds =
    Duration::from_millis(delay_in_milliseconds);
  for column in (1..screen_width).rev() {
    mv(row, column);                        // Could also do "mvaddstr(row, column, "* ");", as below for key-press.
    addstr("* ");
    refresh();    
    thread::sleep(milliseconds);
  }


  /* Wait for a key press. */
  mvaddstr(1, 1, "Press a key to end program...");
  refresh();                                // Technically redundant, since "getch()" will do a refresh of the screen.
  getch();


  /* Terminate ncurses, restore screen. */
  endwin();

} // end of main()

Since the "sl" program only sends its trains across the screen in one direction (right-to-left), we won't need the code to send our asterisk left-to-right. So we can delete the entire first loop block. We also therefore don't need separate speeds for separate directions. Nor, then, does our "milliseconds" variable need to be mutable. Making those changes leaves our program like this:

use ncurses::*;
use std::{thread, time::Duration};

fn main() {
  /* 
    Start ncurses, initializing the screen. Turn
    off keyboard echo to the screen. Turn off
    the display of the cursor. Get the dimensions
    of the terminal window.
  */
  initscr();
  noecho(); 
  curs_set(CURSOR_VISIBILITY::CURSOR_INVISIBLE);
  let mut screen_height = 0;
  let mut screen_width = 0; 
  getmaxyx(
    stdscr(),
    &mut screen_height,
    &mut screen_width
  );


  /* Eventually these will be user-defined variables. For now, the "mut"s will generate compiler warnings. Ignore them. */
  let mut delay_in_milliseconds = 40;       // Set the initial delay between animation frames/cels.
  let mut row = 10;                         // Start out drawing this many rows down the screen.


  /* This converts our delay variable into a type of variable that the "sleep()" command understands. */
  let milliseconds = Duration::from_millis(delay_in_milliseconds);


  /* Move a character across the screen. */
  for column in (1..screen_width).rev() {   // Move from right-side column to left-side column.
    mv(row, column);                        // Move the cursor to these (row,column) coordinates on the screen.
    addstr("* ");                           // "Set the stage" by drawing the character, behind the "lowered stage curtain" (on a hidden screen).
    refresh();                              // "Raise the curtain" / paint the screen, so the user can see our "set stage" (the hidden screen).
    thread::sleep(milliseconds);            // Pause this long for the "audience" to see the "stage" (the now-revealed screen).
  }                                         // Repeat, until we're done.


  /* Wait for a key press. */
  mvaddstr(1, 1, "Press a key to end program...");
  refresh();
  getch();
  

  /* Terminate ncurses, restore screen. */
  endwin();
} // end of main()

We can even get rid of the "milliseconds" variable altogether, and just feed the right-hand side of that assignment statement directly to the "sleep()" function.

...
  /* This converts our delay variable into a type of variable that the "sleep()" command understands. */
  let milliseconds = Duration::from_millis(delay_in_milliseconds);


  /* Move a character across the screen. */
  for column in (1..screen_width).rev() {   // Move from right-side column to left-side column.
    mv(row, column);                        // Move the cursor to these (row,column) coordinates on the screen.
    addstr("* ");                           // "Set the stage" by drawing the character, behind the "lowered stage curtain" (on a hidden screen).
    refresh();                              // "Raise the curtain" / paint the screen, so the user can see our "set stage" (the hidden screen).
    thread::sleep(millisecondsDuration::from_millis(delay_in_milliseconds));            // Pause this long for the "audience" to see the "stage" (the now-revealed screen).
...

I prefer it the original way; seems like smaller (but more numerous) bites to understand the code. But that's just a personal preference. Feel free to code it the way that makes the most sense to you.

Now we need to get some arguments (program switches) from the user, so the user can do things like get help for the program, or cause it to run at a different speed, or to "fly", etc. There's a native way, that's rather limited, and a third-party way called "Clap". We'll look at them both. Let's start with the native way. Click on the Menu at the top of this page, and select the Parsing Rust Args Natively" option.