23 Unfinished projects: Projects for you to finish

This chapter covers

You made it to the last part of the book, well done! J.R.R. Tolkien, the author of Lord of the Rings, wrote quite a few stories that he never finished during his lifetime. These were completed by his son and published under the name Unfinished Tales.

These last two chapters are a sort of Unfinished Tales for you, the developer, to pick up and develop on your own. Each chapter contains three unfinished projects for you to pick up yourself and keep developing. They are finished in the sense that they all work: you can just type cargo run and start using them. But they are meant to be as short as possible, and that means they only have the most basic functionality. After that, it’s up to you to keep working on them if you feel like it.

These two chapters also use quite a few new crates because the crates used for command-line interfaces (CLIs) and graphical user interfaces (GUIs) are best learned through real use on a computer. The crates used in this chapter won’t work on the Playground because they require access to system resources and the ability to do things like take user input in real time and open new windows.

23.1 Setup for the last two chapters

Each of the working code samples for these six unfinished projects will be about 75 to 100 lines long. While short, that’s still a bit too long for us to look at the entire code every time a new line is added. Instead, the code development will be divided into four steps:

23.2 Typing tutor

The first unfinished project is a typing tutor. It takes a text file and displays it, and the user’s job is to type the text that is displayed. The user will be able to see where the text has been typed wrong, and the typing tutor will display how well the user did after the test is over. You can do a search for “typing test” online to see what sort of functionality this small app will aim for.

23.2.1 Setup and first code

This first project uses Crossterm (https://docs.rs/crossterm/latest/crossterm/), a crate that lets you detect and react to user input as it happens. The user input that Crossterm lets you see includes keyboard, mouse, screen resizing, and more, but we only need to monitor the keyboard input. Another nice thing about Crossterm is its size, as it only brings in 22 dependencies and will compile in just a few seconds.

The name Crossterm, by the way, comes from the fact that Rust crates used to be built almost exclusively for Unix/Linux back when the language was still small. Crossterm was the first terminal library that was a crossover, containing support for both Unix and Windows.

The dependencies for this project are pretty simple. Just add this to your Cargo.toml file:

[dependencies]
crossterm = "0.26.1"

Crossterm has a function called read() that is used to see user input, so let’s put it in a loop and see what happens. Try running this code and typing Hi!:

use crossterm::event::read;
 
fn main() {
    loop {
        println!("{:?}", read().unwrap());
    }
}

The output includes both pressing keys and releasing keys, so it will depend somewhat on how you type, but it will probably look something like this:

Key(KeyEvent { code: Char('H'), modifiers: SHIFT, kind: Press, state: NONE
})
Key(KeyEvent { code: Char('i'), modifiers: NONE, kind: Press, state: NONE
})
Key(KeyEvent { code: Char('h'), modifiers: NONE, kind: Release, state: NONE
})
Key(KeyEvent { code: Char('i'), modifiers: NONE, kind: Release, state: NONE
})
Key(KeyEvent { code: Char('!'), modifiers: SHIFT, kind: Press, state: NONE
})
Key(KeyEvent { code: Char('1'), modifiers: NONE, kind: Release, state: NONE
})

Here, the user pressed 'H' with shift, then pressed 'i'. Then the user released 'h' (no shift anymore), then released 'i', then pressed '!', and then released '1' (that is, the ! but without the shift).

If you type more slowly and deliberately, it will look like this:

Key(KeyEvent { code: Char('H'), modifiers: SHIFT, kind: Press, state: NONE
})
Key(KeyEvent { code: Char('h'), modifiers: NONE, kind: Release, state: NONE
})
Key(KeyEvent { code: Char('i'), modifiers: NONE, kind: Press, state: NONE
})
Key(KeyEvent { code: Char('i'), modifiers: NONE, kind: Release, state: NONE
})
Key(KeyEvent { code: Char('!'), modifiers: SHIFT, kind: Press, state: NONE
})

23.2.2 Developing the code

A look through the documentation shows that Key seen in the previous output comes from an enum called Event, which includes events such as Key, Mouse, and Resize:

pub enum Event {
    FocusGained,
    FocusLost,
    Key(KeyEvent),
    Mouse(MouseEvent),
    Paste(String),
    Resize(u16, u16),
}

We only care about Key events, so we can use an if let to react to Key events and ignore the rest.

The Key variant inside Event contains a KeyEvent struct:

pub struct KeyEvent {
    pub code: KeyCode,
    pub modifiers: KeyModifiers,
    pub kind: KeyEventKind,
    pub state: KeyEventState,
}

Let’s see which parameters in the KeyEvent struct we care about:

Now it’s time to pull in a file for the user to try to type. Make a file called typing.txt and put some text—any text—in there. For our output, we’ll assume that the file says "Hi, can you type this?". We can use the read_to_string() function that we learned in chapter 18 to read the file and put the contents into a String, and we’ll make a String called user_input that holds everything the user has typed. And then we’ll print out both Strings one after another. The code is now as follows:

use crossterm::{
    event::{read, Event, KeyCode, KeyEventKind},
};
use std::fs::read_to_string;
 
fn main() {
    let file_content = read_to_string("typing.txt").unwrap();
    let mut user_input = String::new();
 
    loop {
        println!("{file_content}");
        println!("{user_input}_");              
        if let Event::Key(key_event) = read().unwrap() {
            if key_event.kind == KeyEventKind::Press {
                match key_event.code {
                    KeyCode::Backspace => {
                        user_input.pop();
                    }
                    KeyCode::Esc => break,      
                    KeyCode::Char(c) => {
                        user_input.push(c);
                    }
                    _ => {}
                }
            }
        }
    }
}

The underscore shows the user where the cursor is.

This allows the user to escape the program without having to do an ugly Ctrl-C.

The output looks pretty good! As the user types away, the String to type against and the current input are both showing up. The output now looks like this as you type:

Hi, can you type this?
_
Hi, can you type this?
H_
Hi, can you type this?
Hi_
Hi, can you type this?
Hi_
Hi, can you type this?
Hi_
Hi, can you type this?
Hi,_
Hi, can you type this?
Hi,_
Hi, can you type this?
Hi, _
Hi, can you type this?
Hi, _
Hi, can you type this?
Hi, c_

So far, so good, but the output quickly fills up the screen and makes it look messy pretty quickly. And you can probably imagine how much worse it would look if we had to type a longer text. Let’s do something about that!

23.2.3 Further development and cleanup

The next steps are as follows:

  1. We want to clear the screen each time a key is pressed. Crossterm has a macro called execute! that takes a writer (like stdout) and a command. Crossterm commands are simple structs named after what they do: Clear, ScrollDown, SetSize, SetTitle, EnableLineWrap, and so on. We will use Clear, which holds an enum called ClearType that offers a number of ways to clear the screen. We want to clear the whole screen, so we will pass in a Clear(ClearType::All). Putting all this together, the whole line will look like this:

    execute!(stdout(), Clear(ClearType::All));
  2. Instead of just printing out the user input, we can use what we learned in chapter 8 to .zip() together an iterator of the content to type and another iterator of the user’s output. With that, we can compare each character against the other. If they are the same, we will print out the letter, and if they are different (in other words, if the user types the wrong key), we will print out a * instead.

  3. We can calculate the letters typed correctly when the user presses enter to finish the typing test. This is pretty easy and involves another .zip() as previously discussed.

  4. We can replace some .unwrap() calls with the question mark operator.

  5. Finally, we’ll put together a quick App struct that will hold the two strings. This will make main() a bit nicer to read.

The final code is as follows:

use crossterm::{
    event::{read, Event, KeyCode, KeyEventKind},
    execute,
    terminal::{Clear, ClearType},
};
use std::{fs::read_to_string, io::stdout};
 
struct App {
    file_content: String,
    user_input: String,
}
 
impl App {
    fn new(file_name: &str) -> Result<Self, std::io::Error> {
        let file_content = read_to_string(file_name)?;
        Ok(Self {
            file_content,
            user_input: String::new(),
        })
    }
}
 
fn main() -> Result<(), std::io::Error> {
    let mut app = App::new("typing.txt")?;
 
    loop {
        println!("{}", app.file_content);
        for (letter1, letter2) in app.user_input.chars().zip(app.file_content.chars()) {
            if letter1 == letter2 {
                print!("{letter2}");
            } else {
                print!("*");
            }
        }
        println!("_");
        if let Event::Key(key_event) = read()? {
            if key_event.kind == KeyEventKind::Press {
                match key_event.code {
                    KeyCode::Backspace => {
                        app.user_input.pop();
                    }
                    KeyCode::Esc => break,
                    KeyCode::Char(c) => {
                        app.user_input.push(c);
                    }
                    KeyCode::Enter => {
                        let total_chars = app.file_content.chars().count();
                        let total_right = app
                            .user_input
                            .chars()
                            .zip(app.file_content.chars())
                            .filter(|(a, b)| a == b)
                            .count();
                        println!("You got {total_right} out of {total_chars}!");
                        return Ok(());
                    }
                    _ => {}
                }
            }
            execute!(stdout(), Clear(ClearType::All))?;
        }
    }
    Ok(())
}

The output now when using the typing app should look pretty simple—something like this:

Hi, can you type this?
Hi, can**** type thi_

In this case, the user has typed the first characters correctly, made four mistakes, and is now two characters away from finishing the test. The output is pretty clean, but there’s a lot you might want to add to the app now.

23.2.4 Over to you

Now that the basic functionality for the typing tutor works, here are some ideas to continue developing it:

23.3 Wikipedia article summary searcher

The second unfinished project quickly pulls up the summary of a Wikipedia article. Getting started on this project is pretty easy because Wikipedia has an API that doesn’t require registration or a key to use. One of the endpoints on the Wikipedia API gives a summary and some other information for any article, which should be perfect for us. The output from that API is a bit too messy to paste into this book, but you can see a sample output by pasting the following link into your browser and changing PAGE_NAME_HERE to any article name you can think of:

https://en.wikipedia.org/api/rest_v1/page/summary/PAGE_NAME_HERE

23.3.1 Setup and first code

The dependencies for this project are pretty easy because we already know how to use crossterm, and we learned to use reqwest in chapter 19. Putting these two together gives us 105 compiling units. Compiling shouldn’t take too long, but if you want to reduce compiling time, you can choose a crate called ureq (https://docs.rs/ureq/latest/ureq/), which is smaller and simpler than reqwest. Here are the dependencies:

[dependencies]
reqwest = { version = "0.11.16", features = ["blocking"] }
crossterm = "0.26.1"

We can start with something similar to the code in the typing tutor. We’ll have an App struct with a string called user_input that grows and shrinks as before, except that pressing Enter will search Wikipedia. The first code looks like this:

use crossterm::{
    event::{read, Event, KeyCode, KeyEventKind},
    execute,
    terminal::{Clear, ClearType},
};
use reqwest::blocking::get;
use std::io::stdout;
 
#[derive(Debug, Default)]
struct App {
    user_input: String
}
 
const URL: &str = "https://en.wikipedia.org/api/rest_v1/page/summary";
 
fn main() {
    let mut app = App::default();
 
    loop {
        if let Event::Key(key_event) = read().unwrap() {
            if key_event.kind == KeyEventKind::Press {
                execute!(stdout(), Clear(ClearType::All)).unwrap();
                match key_event.code {
                    KeyCode::Backspace => {
                        app.user_input.pop();
                        println!("{}", app.user_input);
                    }
                    KeyCode::Esc => app.user_input.clear(),
                    KeyCode::Enter => {
                        println!("Searching Wikipedia...");
                        let req = get(format!("{URL}/{}", app.user_input))
                        .unwrap();
                        let text = req.text().unwrap();
                        println!("{text}");
                    }
                    KeyCode::Char(c) => {
                        app.user_input.push(c);
                        println!("{}", app.user_input);
                    }
                    _ => {}
                }
            }
        }
    }
}

Easy enough! If we type a nonsense word and press Enter, we get a nice error message:

{"type":"https://mediawiki.org/wiki/HyperSwitch/errors/not_found","title":"
Not found.","method":"get","detail":"Page or revision not found.",
"uri":"/en.wikipedia.org/v1/page/summary/Nthonthoe"}

And if we type a real word like Calgary, we get a massive JSON response that is a little too big to fit here. The response from Wikipedia’s servers is definitely coming in, but we should tidy it up somehow.

23.3.2 Developing the code

The JSON response has a lot of properties that we don’t need like "thumbnail", "wikibase_item", and "revision", but three properties inside it look useful to us: title, description, and extract. Let’s make a struct with those three properties. Then, to deserialize into the struct as we learned in chapter 17, we are going to want to bring in the serde and serde_json crates.

Now, the dependencies are

[dependencies]
reqwest = { version = "0.11.16", features = ["blocking"] }
crossterm = "0.26.1"
serde = { version = "1.0.160", features = ["derive"] }
serde_json = "1.0.96"

By giving our struct the Deserialize trait and using serde_json::from_str() function to convert from JSON, we have a much nicer output:

use crossterm::{
    event::{read, Event, KeyCode, KeyEventKind},
    execute,
    terminal::{Clear, ClearType},
};
use serde::Deserialize;
use std::io::stdout;
 
#[derive(Debug, Deserialize, Default)]
struct App {
    user_input: String
}
 
#[derive(Debug, Deserialize, Default)]
struct CurrentArticle {
    title: String,
    description: String,
    extract: String
}
 
const URL: &str = "https://en.wikipedia.org/api/rest_v1/page/summary";
 
fn main() {
    let mut app = App::default();
 
    loop {
        if let Event::Key(key_event) = read().unwrap() {
            if key_event.kind == KeyEventKind::Press {
                execute!(stdout(), Clear(ClearType::All)).unwrap();
                match key_event.code {
                    KeyCode::Backspace => {
                        app.user_input.pop();
                        println!("{}", app.user_input);
                    }
                    KeyCode::Esc => app.user_input.clear(),
                    KeyCode::Enter => {
                        println!("Searching Wikipedia...");
                        let req = get(format!("{URL}/{}", app.user_input))
                        .unwrap();
                        let text = req.text().unwrap();
                        let as_article: CurrentArticle = 
    serde_json::from_str(&text).unwrap();
                        println!("{as_article:#?}");
                    }
                    KeyCode::Char(c) => {
                        app.user_input.push(c);
                        println!("{}", app.user_input);
                    }
                    _ => {}
                }
            }
        }
    }
}

Okay, let’s type Interlingue (that’s the name of a language) and hit Enter. Quite readable! The output will now look like this:

Searching Wikipedia...
CurrentArticle {
    title: "Interlingue",
    description: "International auxiliary language created 1922",
    extract: "Interlingue, originally Occidental, is an international
    auxiliary language created in 1922 and renamed in 1949. Its creator,
    Edgar de Wahl, sought to achieve maximal grammatical regularity and
    natural character. The vocabulary is based on pre-existing words from
    various languages and a derivational system which uses recognized
    prefixes and suffixes.",
}

23.3.3 Further development and cleanup

Now, let’s improve the output and do some refactoring. The easiest place to start is by removing the calls to .unwrap() and replacing them with the question mark operator. We could use the anyhow crate, but let’s practice the Box<dyn Error> method that we learned in chapter 13. We can move some code out of main into a method for our App called .get_article(), which will return a Result<(), Box<dyn Error>>, which means that main() will return a Result<(), Box<dyn Error>>, too, because it is also using the question mark operator. Also, implementing Display for our App struct will make the output look much nicer.

The code after these changes is as follows:

use crossterm::{
    event::{read, Event, KeyCode, KeyEventKind},
    execute,
    terminal::{Clear, ClearType},
};
use reqwest::blocking::get;
use serde::{Deserialize, Serialize};
use std::{error::Error, io::stdout};
 
#[derive(Debug, Serialize, Deserialize, Default)]
struct CurrentArticle {
    title: String,
    description: String,
    extract: String,
}
 
#[derive(Debug, Default)]
struct App {
    current_article: CurrentArticle,
    search_string: String,
}
 
impl App {
    fn get_article(&mut self) -> Result<(), Box<dyn Error>> {
        let text = get(format!("{URL}/{}", self.search_string))?.text()?;
        if let Ok(article) = serde_json::from_str::<CurrentArticle>(&text) {
            self.current_article = article;
        }
        Ok(())
    }
}
 
impl std::fmt::Display for App {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "
                    Searching for: {}
 
Title: {}
------------
Description: {}
------------
{}",
            self.search_string,
            self.current_article.title,
            self.current_article.description,
            self.current_article.extract
        )
    }
}
 
const URL: &str = "https://en.wikipedia.org/api/rest_v1/page/summary";
 
fn main() -> Result<(), Box<dyn Error>> {
    let mut app = App::default();
 
    loop {
        println!("{app}");
        if let Event::Key(key_event) = read()? {
            if key_event.kind == KeyEventKind::Press {
                match key_event.code {
                    KeyCode::Backspace => {
                        app.search_string.pop();
                    }
                    KeyCode::Esc => app.search_string.clear(),
                    KeyCode::Enter => app.get_article()?,
                    KeyCode::Char(c) => {
                        app.search_string.push(c);
                    }
                    _ => {}
                }
            }
            execute!(stdout(), Clear(ClearType::All))?;
        }
    }
}

Now the output looks pretty clean!

                    Searching for: Interlingue
 
Title: Interlingue
------------
Description: International auxiliary language created 1922
------------
Interlingue, originally Occidental, is an international auxiliary language
created in 1922 and renamed in 1949. Its creator, Edgar de Wahl,
sought to achieve maximal grammatical regularity and natural
character. The vocabulary is based on pre-existing words from various
languages and a derivational system which uses recognized prefixes and
suffixes.

23.3.4 Over to you

Let’s think about what could be developed with this app now that the basic functionality works:

23.4 Terminal stopwatch and clock

The third project we will make is a text- or terminal-based user interface (TUI) that holds a stopwatch and a clock. In the previous examples, we used Crossterm on its own, which worked well enough. But there are crates that allow you to put together a terminal-based app that looks surprisingly nice. These are fairly popular because they are quick to compile, extremely responsive, and run inside the same terminal window we use to cargo run our programs.

23.4.1 Setup and first code

The main crate for TUIs in Rust is called ratatui (https://docs.rs/ratatui/latest/ratatui/), which, in fact, uses crossterm on its backend. To be precise, the main crate is/was known as tui, but the owner of the crate ran out of time to maintain the crate (real life will do that to you sometimes; https://github.com/fdehau/tui-rs/issues/654), and it was forked under the new name ratatui. The original tui works just fine, but ratatui is being actively maintained and has new features added, so we will go with ratatui.

NOTE We will use version 0.21 of ratatui, which is more or less the same as the original tui crate, but at the time of publication ratatui has reached version 0.24 with quite a few new features added! By the time you read this book, there may be even more.

Every GUI and TUI crate has its own preferred method for building user interfaces, meaning that the quickest way to get started is to look for a working example. Inside ratatui is a Terminal that holds a backend, such as the crossterm backend. There is also a method called .draw() to draw the output on the screen, which we will run in a loop. Inside this method is a closure, which provides a struct on which we can use methods to create widgets and then call .render_widget() to display them.

Inside every loop, we will create a Layout, give it a direction (horizontal or vertical), set the size of each part of the layout by giving them constraints, and then split the layout into parts based on the total screen size. We want a simple horizontal app split 50% each way, so it will be set up as follows. Note the builder pattern here:

let layout = Layout::default()
    .direction(Direction::Horizontal)
    .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
    .split(f.size());             
let stopwatch_area = layout[0];
let utc_time_area = layout[1];

The f is a struct called a Frame that the closure gives us access to. The f is a variable name and could be any other name as well.

With this code, we have split up the layout, but we haven’t made anything to display yet. To display something, we can choose among some of the widgets that ratatui offers, such as BarChart, Block, Dataset, Row, Table, Paragraph, and so on. A Paragraph lets us display some text, and we can put it inside a Block, which is the base widget used to give other widgets a border. All together it looks like this:

let stopwatch_block = Block::default().title("Stopwatch").borders(Borders::ALL);
stopwatch_text = Paragraph::new("First block").block(stopwatch_block);

And then finally comes the .render_widget() method, which takes a widget and an area:

f.render_widget(stopwatch_text, stopwatch_area);

That was quite a bit of typing, but nothing too complex: we are just instructing the TUI what to display. Let’s put it all together now:

use std::io::stdout;
use ratatui::{
    backend::CrosstermBackend,
    layout::{Constraint, Direction, Layout},
    widgets::{Block, Borders, Paragraph},
    Terminal,
};
 
fn main() {
    let stdout = stdout();
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend).unwrap();             
 
    loop {
        terminal
            .draw(|f| {
                let layout = Layout::default()
                    .direction(Direction::Horizontal)
                    .constraints([Constraint::Percentage(50), Constraint::
                    Percentage(50)])
                    .split(f.size());
                let stopwatch_area = layout[0];
                let utc_time_area = layout[1];
 
                let stopwatch_block = Block::default().title("Stopwatch").bord
     ers(Borders::ALL);
                let utc_time_block = Block::default().title("UTC time").bord
                ers(Borders::ALL);
 
                let stopwatch_text = Paragraph::new("I'm a stopwatch").block
                (stopwatch_block);
                let utc_text = Paragraph::new("Hi I'm in London").block(utc_
                time_block);
 
                f.render_widget(stopwatch_text, stopwatch_area);
                f.render_widget(utc_text, utc_time_area);
 
            })
            .unwrap();
        std::thread::sleep(std::time::Duration::from_millis(20));   
        terminal.clear().unwrap();                                  
    }
}

The ratatui Terminal takes a crossterm backend.

The terminal is going to loop as fast as it possibly can, so let’s put it to sleep each time to keep the screen from flickering. Using .sleep() can be a bad idea in complex and async code, but we are just running a little terminal app on a single thread.

Ratatui has a convenience method called .clear(), so we don’t need to use a crossterm command to clear the screen anymore.

If you run the code now, you should see a pretty nice-looking terminal interface (figure 23.1). It isn’t doing anything at the moment, but you can resize the window and watch how the display changes. This is a lot slicker than just using crossterm!

Figure 23.1 Terminal interface

23.4.2 Developing the code

It’s now time to start implementing the clock and stopwatch. We’ve used the chrono crate before in chapter 17, so this should not be too hard. Getting the current UTC datetime is quite easy:

chrono::offset::Utc::now();

Printing this out will look something like this:

2023-06-10T04:13:05.169920165Z

That output isn’t too readable. Fortunately, the DateTime struct in chrono has a method called .format() that lets us specify how we want it to look. This method takes a &str that recognizes tokens after a % sign, such as %Y to display the year, %H to display the hour, and so on (http://mng.bz/A8Gp). Let’s give it a try:

chrono::offset::Utc::now().format("%Y/%m/%d %H:%M:%S")

Now the output is much better:

2023/06/10 04:18:46

For the stopwatch, we are going to have to think a bit. A stopwatch should have three states:

That sounds like an enum with three variants. Fortunately, the state changes are easy: if the user presses a key, the stopwatch starts running. Another key press stops it, and the stopwatch shows the time that passed. Finally, another key resets it, bringing it back to a “not started” state.

The last thing to figure out is how to display the time. This isn’t too hard either, thanks to the Instant struct we learned to use in chapter 17. When the stopwatch starts, it should hold an Instant::now(), and as it runs or it has stopped, it should use .elapsed().millis() on the Instant to see how much time has passed in milliseconds.

Following this, all we need to do is to “pull off” the minutes, the seconds, and the milliseconds—in that order. For example, if the stopwatch stops and 70,555 milliseconds have passed, the code should do the following steps:

  1. One minute has 60,000 milliseconds. See how many minutes have passed: 70,555 / 60,000 = 1 minute.

  2. Subtract the minutes in milliseconds: 70,555 − 1 * 60,000 = 10,555 milliseconds left.

  3. One second has 1,000 milliseconds. See how many seconds have passed: 10,555 / 1,000 = 10 seconds.

  4. Subtract the seconds in milliseconds: 10,555 − 10 * 1,000 = 555 milliseconds left.

  5. Finally, divide the milliseconds by 10 to get the split seconds (hundredths of a second).

When you put all that together, our Stopwatch struct will handle the logic like this:

    fn new() -> Self {
        Self {
            now: Instant::now(),
            state: StopwatchState::NotStarted,
            display: String::from("0:00:00"),
        }
    }
    fn get_time(&self) -> String {
        use StopwatchState::*;
        match self.state {
            NotStarted => String::from("0:00:00"),
            Running => {
                let mut elapsed = self.now.elapsed().as_millis();
                let minutes = elapsed / 60000;        
                elapsed -= minutes * 60000;           
                let seconds = elapsed / 1000;         
                elapsed -= seconds * 1000;
                let split_seconds = elapsed / 10;
                format!("{minutes}:{seconds}:{split_seconds}")
            }
            Done => self.display.clone(),
        }
    }

Here, we see how many full minutes there are to display

Then we subtract these minutes in milliseconds from the total time elapsed.

Then we repeat with the next largest unit, seconds. And so on.

With the stopwatch logic out of the way, we now have a working clock and stopwatch to play around with.

The last new entry to the code is the poll() method inside crossterm, which we will use instead of read(). Using read() waits until a user event takes place, but we want the stopwatch to run even if nobody is pressing any keys. The poll() method lets you specify a Duration to wait for an event. In our case, we will enter 0 for the Duration. Doing so will let us quickly check for a key event every loop, followed by redrawing the screen.

Here is the code we have so far:

use std::{
    io::stdout,
    time::Instant,
};
 
use crossterm::event::{poll, read, Event, KeyCode, KeyEventKind};
use ratatui::{
    backend::CrosstermBackend,
    layout::{Constraint, Direction, Layout},
    widgets::{Block, Borders, Paragraph},
    Terminal,
};
 
struct Stopwatch {
    now: Instant,
    state: StopwatchState,
    display: String,
}
 
enum StopwatchState {
    NotStarted,
    Running,
    Done,
}
 
impl Stopwatch {
    fn new() -> Self {
        Self {
            now: Instant::now(),
            state: StopwatchState::NotStarted,
            display: String::from("0:00:00"),
        }
    }
    fn get_time(&self) -> String {
        use StopwatchState::*;
        match self.state {
            NotStarted => String::from("0:00:00"),
            Running => {
                let mut elapsed = self.now.elapsed().as_millis();
                let minutes = elapsed / 60000;
                elapsed -= minutes * 60000;
                let seconds = elapsed / 1000;
                elapsed -= seconds * 1000;
                let split_seconds = elapsed / 10;
                format!("{minutes}:{seconds}:{split_seconds}")
            }
            Done => self.display.clone(),
        }
    }
    fn next_state(&mut self) {
        use StopwatchState::*;
        match self.state {
            NotStarted => {
                self.now = Instant::now();
                self.state = Running;
            }
            Running => {
                self.display = self.get_time();
                self.state = Done;
            }
            Done => self.state = NotStarted,
        }
    }
}
 
fn main() {
    let stdout = stdout();
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend).unwrap();
    let mut stopwatch = Stopwatch::new();
 
    loop {
        if poll(std::time::Duration::from_millis(0)).unwrap() {
            if let Event::Key(key_event) = read().unwrap() {
                if let (KeyCode::Enter, KeyEventKind::Press) = (key_event.co
                de, key_event.kind) {
                    stopwatch.next_state();
                }
            }
        }
 
        terminal
            .draw(|f| {
                let layout = Layout::default()
                    .direction(Direction::Horizontal)
                    .constraints([Constraint::Percentage(50), Constraint::Pe
                    rcentage(50)])
                    .split(f.size());
                let stopwatch_area = layout[0];
                let utc_time_area = layout[1];
 
                let stopwatch_block = Block::default().title("Stopwatch").bo
                rders(Borders::ALL);
                let utc_time_block = Block::default()
                    .title("UTC time")
                    .borders(Borders::ALL);
                
                let stopwatch_text = Paragraph::new(stopwatch.get_time()).bl
                ock(stopwatch_block);
                let utc_text = Paragraph::new(chrono::offset::Utc::now().for
                mat("%Y/%m/%d %H:%M:%S").to_string())
                    .block(utc_time_block);
                
                f.render_widget(stopwatch_text, stopwatch_area);
                f.render_widget(utc_text, utc_time_area);
            })
            .unwrap();
        std::thread::sleep(std::time::Duration::from_millis(20));
        terminal.clear().unwrap();
    }
}

The output on your screen should now look like figure 23.2.

Figure 23.2 Updated terminal interface

23.4.3 Further development and cleanup

The code is working quite well, so let’s focus on cleaning it up. As always, we can replace calls to .unwrap() with the question mark operator. Let’s practice with anyhow this time by returning a Result<(), anyhow::Error> inside main(). The dependencies inside Cargo.toml should now look like this:

[dependencies]
anyhow = "1.0.71"
chrono = "0.4"
crossterm = "0.26.1"
ratatui = "0.21"

So what else should we clean up?

The builder pattern in ratatui makes it easy to set up an app, but it is also quite wordy. Let’s do some general readability cleanup, too, while we are at it. Instead of calling Block::default().title and so on twice inside main, we can put together a quick helper function that takes a &str and returns a Block. The same will go for the call to generate a formatted UTC time, which makes the line in main really long. This can be a helper function, too.

This sort of readability cleanup is a personal decision, but a good general rule is that helper functions can be good for readability as long as the important information can be seen in the first function. However, too many helper functions can be bad for readability. Writing a helper function that calls another helper function and then another helper function can help each function be nice and small, but it will take a lot of clicking for the reader of your code to finally find out exactly what is being done. Imagining yourself reading your own code one year later is a good way to decide how to refactor your code for readability.

In any case, here is what our code looks like now with some cleanup done:

use std::{io::stdout, thread::sleep, time::Duration, time::Instant};
use chrono::offset::Utc;
use crossterm::event::{poll, read, Event, KeyCode, KeyEventKind};
use ratatui::{
    backend::CrosstermBackend,
    layout::{Constraint, Direction, Layout},
    widgets::{Block, Borders, Paragraph},
    Terminal,
};
 
struct Stopwatch {
    now: Instant,
    state: StopwatchState,
    display: String,
}
 
enum StopwatchState {
    NotStarted,
    Running,
    Done,
}
 
impl Stopwatch {
    fn new() -> Self {
        Self {
            now: Instant::now(),
            state: StopwatchState::NotStarted,
            display: String::from("0:00:00"),
        }
    }
    fn get_time(&self) -> String {
        use StopwatchState::*;
        match self.state {
            NotStarted => String::from("0:00:00"),
            Running => {
                let mut elapsed = self.now.elapsed().as_millis();
                let minutes = elapsed / 60000;
                elapsed -= minutes * 60000;
                let seconds = elapsed / 1000;
                elapsed -= seconds * 1000;
                let split_seconds = elapsed / 10;
                format!("{minutes}:{seconds}:{split_seconds}")
            }
            Done => self.display.clone(),
        }
    }
    fn next_state(&mut self) {
        use StopwatchState::*;
        match self.state {
            NotStarted => {
                self.now = Instant::now();
                self.state = Running;
            }
            Running => {
                self.display = self.get_time();
                self.state = Done;
            }
            Done => self.state = NotStarted,
        }
    }
}
 
fn block_with(input: &str) -> Block {
    Block::default().title(input).borders(Borders::ALL)
}
 
fn utc_pretty() -> String {
    Utc::now().format("%Y/%m/%d %H:%M:%S").to_string()
}
 
fn main() -> Result<(), anyhow::Error> {
    let stdout = stdout();
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;
    let mut stopwatch = Stopwatch::new();
 
    loop {
        if poll(Duration::from_millis(0))? {
            if let Event::Key(key_event) = read()? {
                if let (KeyCode::Enter, KeyEventKind::Press) = (key_event
                .code, key_event.kind) {
                    stopwatch.next_state();
                }
            }
        }
 
        terminal.draw(|f| {
            let layout = Layout::default()
                .direction(Direction::Horizontal)
                .constraints([Constraint::Percentage(50), Constraint::Percen
                tage(50)])
                .split(f.size());
 
            let stopwatch_area = layout[0];
            let utc_time_area = layout[1];
 
            let stopwatch_block = block_with("Stopwatch");
            let utc_time_block = block_with("Time in London");
 
            let stopwatch_text = Paragraph::new(stopwatch.get_time()).block(
            stopwatch_block);
            let utc_text = Paragraph::new(utc_pretty()).block(utc_time_block
            );
 
            f.render_widget(stopwatch_text, stopwatch_area);
            f.render_widget(utc_text, utc_time_area);
        })?;
        sleep(Duration::from_millis(20));
        terminal.clear()?;
    }
}

23.4.4 Over to you

There’s quite a bit that you might want to add to this app. Here are some ideas:

Hopefully, you enjoyed these first three projects! Ideally, they should give you both a sense of satisfaction and a desire to keep working on and improving them. The next chapter is the very last chapter of the book and will continue with another three projects for you to keep developing. See you there!

Summary