Terminal-based menu

I really want to move into using a GUI, but right now the state of GUI's in Rust is, well, not that great. So, while I will eventually need to move into using a GUI, for now I'm going to limit my project to the terminal. That means I've had to set up several menus that allow a user to make choices about how to use the program. I developed some basic code that I can copy/paste whenever I need a new menu and would like to get some input on it. I'd also love it if anyone had some suggestions for different ways to approach writing menus in the terminal. Here's the code:

fn main() {
    menu();
    println!("\n  And we're done!!");
}

fn menu() {
    println!("\n Choose your activity:   ");
    println!("\n   1. Activity 11111111.");
    println!("\n   2. Activity 22222222.");
    let choice = input_num_prompt_range("\n Please choose:   ", 1, 2);

    match choice {
        1 => {
            println!("\n You chose Activity 1111111!!  Good job!  :>) \n");
        }
        2 => {
            println!("\n You chose Activity 2222222!!  Good job!  :>) \n");
        }
        _ => {}
    }
    println!("\n The menu choice is:   {}", choice);
}

pub fn input_num_prompt_range(prompt: &str, min: i64, max: i64) -> i64 {
    use std::io::{self, Write};

    loop {
        print!("{}", prompt);
        let _ = io::stdout().flush().expect("Failed to flush stdout.");
        let mut input = String::new();

        io::stdin()
            .read_line(&mut input)
            .expect("Failed to read input.");

        let input = match input.trim().parse() {
            Ok(parsed_input) => parsed_input,
            Err(_) => continue,
        };

        if (min..=max).contains(&input) {
            return input;
        } else {
            println!("\n That choice isn't allowed!  \n");
            continue;
        };
    }
}

Thoughts? Thanks!!

One person's "not that great" is another person's "definitely more than good enough".

Have you considered ratatui? It's pretty darn powerful.

Otherwise, the code sample is quite good enough, I would say. It looks similar to the code from Programming a Guessing Game - The Rust Programming Language (rust-lang.org).

The main thing is that your "input widgets" being split out into functions is a great first approximation to the GUI. For instance, a "password input" would want to disable and then reenable terminal echo, and maybe echo "*" for each character and handle backspace correctly. Encapsulating all of that in a single function should work well enough when you only need to wait on input and don't have anything else to do on the thread.

6 Likes

For select menus outside of a TUI context (in that case you'll want ratatui which was previously recommended), I quite like dialoguer for its wide range of options. The only downside that you may have to consider is that Select returns the index of the item, not the item itself.

use std::error::Error;
use dialoguer::Select;

fn main() -> Result<(), Box<dyn Error>> {
    let opts = ["Activity 1", "Activity 2"];
    let index = Select::new()
        .with_prompt("Pick an Activity:")
        .items(&opts)
        .interact()?;
        
    println!("You selected {}!", opts[index]);
    
    Ok(())
}
2 Likes

If you don't want to bring in and learn an external crate, you can improve your existing code some.

In particular, I'd make menu() reusable by taking a Vec of choices and returning the one that the user selected. That will minimize the amount of code that you need to copy/paste when you implement a new menu, which will in turn mean that any enhancements or bug fixes that you make to the menu code in the future gets automatically applied to all of them.

For example:

use std::fmt::Display;

fn main() {
    let activity = menu("Choose your activity", vec![Activity::One, Activity::Two]);
    println!("\n You chose {activity}!!  Good job!  :>) \n");
    println!("\n  And we're done!!");
}

enum Activity { One, Two }

impl Display for Activity {
    fn fmt(&self, out: &mut std::fmt::Formatter<'_>)-> std::fmt::Result {
        match self {
            Activity::One => write!(out, "Activity 11111111"),
            Activity::Two => write!(out, "Activity 22222222"),
        }
    }
}

fn menu<T:Display>(prompt: &str, mut choices: Vec<T>)->T {
    println!("\n {prompt}:");
    for (i, opt) in choices.iter().enumerate() {
        let i = i + 1;
        println!("   {i}. {opt}.");
    }
    let choice = input_num_prompt_range("\n Please choose:   ", 1, choices.len());
    choices.swap_remove(choice - 1)
}

pub fn input_num_prompt_range(prompt: &str, min: usize, max: usize) -> usize {
    // Unchanged, except for the integer type used
}
1 Like

Thank you. I was hoping it would be okay. I'm going to stick with it for now, although I really like the solution suggested by @2e71828.

My issue with existing GUI's in Rust is the massive learning curve with documentation that is either poorly done or nonexistent. If I was coming at it with a lot of experience under my belt it might be different. I must have researched 8-10 different Rust GUI crates and stalled on all of them. My favorite -- Iced -- is one of those that has almost no documentation.

I checked out ratatui. It seems very well documented, so might be workable. My concern about using a terminal-based UI is whether it will limit my project's ability to move across platforms and how it will work with users who are not all that computer literate. Right now this project is strictly for my personal use, but the potential is there to target the homeschool market or even the education community at large. That would mean porting the code to Windows and Mac OS at the very least. If I build with ratatui, will that limit my ability to move from using a Linux terminal to other platforms?

Are you saying my code is good for this as is or do I need to change things to make it more GUI compatible?

I appreciate your comments. Thanks.

1 Like

Thanks for your reply. I checked out the documentation for dialoguer and am going to play around with the code snippet you provided. I may have to come back with some questions.

The comment you referenced was a positive endorsement of the style you used. Encapsulating the behavior of the "widget" in something like a function is good design!

2 Likes

Thanks! :grinning:

So, I'm getting real tired of using endless match statements in my menus, so I came back to this topic to see if one of the suggestions here would be better to use. I decided to try yours out first. It's short and very clean and that is quite appealing to me. Spent some time exploring the documentation for the dialoguer crate and then created a new Cargo project and copy/pasted your above code. Added dialoguer = "0.11.0" to my cargo.toml file and ran the code. What happened was interesting. The activity prompts printed out just fine:

Pick an Activity::
  Activity 1
  Activity 2

but then the cursor disappeared and the app froze. Had to ctrl-c my way out of it. I went back to the documentation to see if I could find a clue about what happened, but it remains a mystery. Any idea what is going on? Oh, and the cursor never came back to my terminal. I had to close and restart to get it back.

Odd... (On phone right now, I'll try to reproduce later)

Could you send through an Asciinema recording of your terminal with this happening? Also what terminal emulator are you using?

Dialoguer does not automatically pick a select item to start on. It waits for you to press up or down to show which item is selected. Update your code as follows:

    let index = Select::new()
        .with_prompt("Pick an Activity:")
        .items(&opts)
+       .default(0)
        .interact()?;

As for your cursor disappearing, this only happens on Ctrl-c and persists because the function to show the cursor again isn't run on abort.

See select.rs - source for more info.

Thanks! That got it working. Part of the problem was that I was expecting to simply enter a '1' or '2' from the keyboard and wasn't getting a response. Didn't even think about using the up or down arrows. Now I have something I can play with.

Appreciate your help! :nerd_face: