Skip to content

Instantly share code, notes, and snippets.

@gebeer
Last active May 22, 2024 14:16
Show Gist options
  • Save gebeer/f7bbc4e1d6775953fbed13acbab4bd2c to your computer and use it in GitHub Desktop.
Save gebeer/f7bbc4e1d6775953fbed13acbab4bd2c to your computer and use it in GitHub Desktop.
Simple Terminal Menu using ratatui for total Rust noob with the help of GPT-4o in Cursor editor

This is inspired by Chris Titus' video on setting up a terminal menu with Rust using the Cursor editor's AI features.

As a programmer who regularly uses the Cursor editor and its AI capabilities, I found the video somewhat challenging to watch. This is not meant as a critique of Chris Titus but rather a suggestion for a more efficient approach. Familiarizing oneself with Cursor's features and improving prompting skills can significantly enhance the experience.

My goal is not to criticize but to provide a more efficient strategy for achieving the same goal, hoping it will be beneficial to others.

I am primarily a PHP-focused web developer and have never worked with Rust before. I wanted to see if I could leverage the AI features in Cursor to set up the entire project quickly. Below are the steps I followed, along with the conversations I had until the basic menu was operational. I didn't need to read any documentation myself.

Steps I took:

  1. Installed necessary tools (rustup, cargo, etc.) in a development shell.
  2. Explained to the AI that I am new to Rust and requested guidance on setting up a "Hello, World!" project with the required dependencies (ratatui), following the AI's instructions.
  3. Provided the AI with the ratatui documentation using the @Docs command in Cursor chat.
  4. Formulated a specific prompt to explain the task to the AI and gave detailed instructions on how to proceed, referencing the ratatui documentation.
  5. Iterated a few times to resolve minor bugs.

After three iterations, I had a working menu. The entire process took about 30 minutes, including setting up Rust in a NixOS devbox development shell and configuring useful Rust extensions in VSCode.

Attached to this gist are the main prompt I used to kick off the development of the menu as well as protocols of my conversations with the AI. following that prompt.

TASK: As a senior rust developer you setup a simple terminal menu with three items using ratatui: Item 1 Item 2 Item 3 Items should be selectable by up and down arrow keys. selected item should be highlighted with blue background. Each item invokes a corresponding function on enter. implement dummy functions that output "Item 1", "Item 2", "Item3" when invoked. use the tailwind colors supplied by ratatui. confirm that you understand the TASK at hand

EXECUTION INSTRUCTIONS If you have questions, ask for clarifications. Before outputting actual code follow these steps to come up with a plan for execution:

  1. think thoroughly about the task at hand
  2. Break it down in steps
  3. Summarize the plan
  4. review and rethink the plan and make adjustments if necessary

Do not start coding before you are absolutely sure that you will produce well structured and efficient working code that follows best practices. Please confirm the EXECUTION INSTRUCTIONS before you start.

@Ratatui

TASK: As a senior rust developer you setup a simple terminal menu with three items using ratatui:
Item 1
Item 2
Item 3
Items should be selectable by up and down arrow keys. selected item should be highlighted with blue background. Each item invokes a corresponding function on enter. implement dummy functions that output "Item 1", "Item 2", "Item3" when invoked. use the tailwind colors supplied by ratatui. confirm that you understand the TASK at hand

EXECUTION INSTRUCTIONS
If you have questions, ask for clarifications.
Before outputting actual code follow these steps to come up with a plan for execution:

  1. think thoroughly about the task at hand
  2. Break it down in steps
  3. Summarize the plan
  4. review and rethink the plan and make adjustments if necessary

Do not start coding before you are absolutely sure that you will produce well structured and efficient working code that follows best practices. Please confirm the EXECUTION INSTRUCTIONS before you start.

USED @main.rs @Ratatui

I understand the task and the execution instructions. Here's a summary of the plan:

Plan

Setup Dependencies:

  • Add ratatui and crossterm dependencies to Cargo.toml.

Create the Terminal UI:

  • Initialize the terminal using crossterm.
  • Create a list of items for the menu.
  • Implement navigation using up and down arrow keys.
  • Highlight the selected item with a blue background.

Handle User Input:

  • Capture key events to navigate the menu.
  • Invoke corresponding functions when the Enter key is pressed.

Implement Dummy Functions:

  • Create functions that print "Item 1", "Item 2", and "Item 3".

Main Function:

  • Combine all the above steps in the main function to run the application.

Steps

Setup Dependencies:

  • Add ratatui and crossterm to Cargo.toml.

Initialize Terminal:

  • Use crossterm to setup terminal input and output.

Create Menu Items:

  • Define a list of items and their corresponding functions.

Navigation Logic:

  • Implement logic to navigate through the menu using arrow keys.

Highlight Selected Item:

  • Use ratatui to render the menu and highlight the selected item.

Invoke Functions:

  • Call the appropriate function when the Enter key is pressed.

Review and Adjustments:

  • Ensure the code is modular and follows Rust best practices.
  • Handle terminal cleanup properly to avoid leaving the terminal in an inconsistent state.

If everything looks good, I will proceed with the implementation. Please confirm if this plan is acceptable or if any adjustments are needed.

Step 1 has been done. Proceed with steps 2 and following.

Great! Let's proceed with the implementation.

Step 2: Initialize Terminal

We'll start by setting up the terminal using crossterm and ratatui.

main.rs

use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::{Backend, CrosstermBackend},
    layout::{Constraint, Direction, Layout},
    style::{Color, Modifier, Style},
    text::{Span, Spans},
    widgets::{Block, Borders, List, ListItem},
    Terminal,
};
use std::io;
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    // Setup terminal
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    // Run the app
    let res = run_app(&mut terminal);

    // Restore terminal
    disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;

    if let Err(err) = res {
        println!("{:?}", err)
    }

    Ok(())
}

Step 3: Create Menu Items

Define the menu items and their corresponding functions.

main.rs

fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> io::Result<()> {
    let menu_items = vec!["Item 1", "Item 2", "Item 3"];
    let mut selected = 0;

    loop {
        terminal.draw(|f| {
            let size = f.size();
            let chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Percentage(100)].as_ref())
                .split(size);

            let items: Vec<ListItem> = menu_items
                .iter()
                .enumerate()
                .map(|(i, &item)| {
                    let style = if i == selected {
                        Style::default().bg(Color::Blue)
                    } else {
                        Style::default()
                    };
                    ListItem::new(Spans::from(Span::styled(item, style)))
                })
                .collect();

            let list = List::new(items)
                .block(Block::default().borders(Borders::ALL).title("Menu"))
                .highlight_style(Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD));

            f.render_widget(list, chunks[0]);
        })?;

        if let Event::Key(key) = event::read()? {
            match key.code {
                KeyCode::Up => {
                    if selected > 0 {
                        selected -= 1;
                    }
                }
                KeyCode::Down => {
                    if selected < menu_items.len() - 1 {
                        selected += 1;
                    }
                }
                KeyCode::Enter => {
                    match selected {
                        0 => item_1(),
                        1 => item_2(),
                        2 => item_3(),
                        _ => {}
                    }
                }
                KeyCode::Esc => return Ok(()),
                _ => {}
            }
        }
    }
}

Step 4: Implement Dummy Functions

Create the dummy functions that print the corresponding item.

main.rs

fn item_1() {
    println!("Item 1");
}

fn item_2() {
    println!("Item 2");
}

fn item_3() {
    println!("Item 3");
}

Full Code

Combining all the steps, the full code in main.rs should look like this:

main.rs

use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::{Backend, CrosstermBackend},
    layout::{Constraint, Direction, Layout},
    style::{Color, Modifier, Style},
    text::{Span, Spans},
    widgets::{Block, Borders, List, ListItem},
    Terminal,
};
use std::io;
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    // Setup terminal
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    // Run the app
    let res = run_app(&mut terminal);

    // Restore terminal
    disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;

    if let Err(err) = res {
        println!("{:?}", err)
    }

    Ok(())
}

fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> io::Result<()> {
    let menu_items = vec!["Item 1", "Item 2", "Item 3"];
    let mut selected = 0;

    loop {
        terminal.draw(|f| {
            let size = f.size();
            let chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Percentage(100)].as_ref())
                .split(size);

            let items: Vec<ListItem> = menu_items
                .iter()
                .enumerate()
                .map(|(i, &item)| {
                    let style = if i == selected {
                        Style::default().bg(Color::Blue)
                    } else {
                        Style::default()
                    };
                    ListItem::new(Spans::from(Span::styled(item, style)))
                })
                .collect();

            let list = List::new(items)
                .block(Block::default().borders(Borders::ALL).title("Menu"))
                .highlight_style(Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD));

            f.render_widget(list, chunks[0]);
        })?;

        if let Event::Key(key) = event::read()? {
            match key.code {
                KeyCode::Up => {
                    if selected > 0 {
                        selected -= 1;
                    }
                }
                KeyCode::Down => {
                    if selected < menu_items.len() - 1 {
                        selected += 1;
                    }
                }
                KeyCode::Enter => {
                    match selected {
                        0 => item_1(),
                        1 => item_2(),
                        2 => item_3(),
                        _ => {}
                    }
                }
                KeyCode::Esc => return Ok(()),
                _ => {}
            }
        }
    }
}

fn item_1() {
    println!("Item 1");
}

fn item_2() {
    println!("Item 2");
}

fn item_3() {
    println!("Item 3");
}

This completes the implementation. The code sets up a terminal UI with a menu that allows navigation using the up and down arrow keys, highlights the selected item with a blue background, and invokes corresponding functions when the Enter key is pressed.

how do I run main.rs

USED @main.rs @Ratatui

To run main.rs, you need to have Rust installed on your system. If you haven't installed Rust yet, you can do so by following the instructions on the official Rust website.

Once Rust is installed, follow these steps:

Navigate to the Project Directory

Open your terminal and navigate to the directory containing main.rs.

Build and Run the Project

Use the following commands to build and run your Rust project:

   cargo run

This command will compile your project and execute the main function in main.rs. If you encounter any errors during compilation or execution, please provide the error messages, and I can help you troubleshoot them.

This outputs 'Hello, World.' Cargo doesn't seem to be aware of the changes in main.rs.

It sounds like you might be running a default Rust project that still has the initial "Hello, World!" code. Let's ensure your project is set up correctly.

Steps to Ensure Correct Setup

Check Project Structure

Ensure your project structure looks like this:

   your_project/
   ├── Cargo.toml
   └── src/
       └── main.rs

Edit main.rs

Make sure the main.rs file in the src directory contains the code we discussed.

Check Cargo.toml

Ensure your Cargo.toml file includes the necessary dependencies:

   [package]
   name = "your_project"
   version = "0.1.0"
   edition = "2018"

   [dependencies]
   crossterm = "0.20"
   ratatui = "0.1"

Run the Project

Navigate to the root directory of your project (where Cargo.toml is located) and run:

   cargo run

Example Cargo.toml

Here is an example Cargo.toml for reference:

[package]
name = "your_project"
version = "0.1.0"
edition = "2018"

[dependencies]
crossterm = "0.20"
ratatui = "0.1"

Example main.rs

Ensure your src/main.rs contains the following code:

use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::{Backend, CrosstermBackend},
    layout::{Constraint, Direction, Layout},
    style::{Color, Modifier, Style},
    text::{Span, Spans},
    widgets::{Block, Borders, List, ListItem},
    Terminal,
};
use std::io;
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    // Setup terminal
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    // Run the app
    let res = run_app(&mut terminal);

    // Restore terminal
    disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;

    if let Err(err) = res {
        println!("{:?}", err)
    }

    Ok(())
}

fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> io::Result<()> {
    let menu_items = vec!["Item 1", "Item 2", "Item 3"];
    let mut selected = 0;

    loop {
        terminal.draw(|f| {
            let size = f.size();
            let chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Percentage(100)].as_ref())
                .split(size);

            let items: Vec<ListItem> = menu_items
                .iter()
                .enumerate()
                .map(|(i, &item)| {
                    let style = if i == selected {
                        Style::default().bg(Color::Blue)
                    } else {
                        Style::default()
                    };
                    ListItem::new(Spans::from(Span::styled(item, style)))
                })
                .collect();

            let list = List::new(items)
                .block(Block::default().borders(Borders::ALL).title("Menu"))
                .highlight_style(Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD));

            f.render_widget(list, chunks[0]);
        })?;

        if let Event::Key(key) = event::read()? {
            match key.code {
                KeyCode::Up => {
                    if selected > 0 {
                        selected -= 1;
                    }
                }
                KeyCode::Down => {
                    if selected < menu_items.len() - 1 {
                        selected += 1;
                    }
                }
                KeyCode::Enter => {
                    match selected {
                        0 => item_1(),
                        1 => item_2(),
                        2 => item_3(),
                        _ => {}
                    }
                }
                KeyCode::Esc => return Ok(()),
                _ => {}
            }
        }
    }
}

fn item_1() {

Oh, I updated /main.rs in the root of my project and not /src/main.rs. Can I safely remove /main.rs?

USED no context

Yes, you can safely remove the /main.rs file in the root of your project. The correct location for the main.rs file in a Rust project is within the src directory.

Steps to Remove /main.rs and Ensure Correct Setup

Remove /main.rs

Navigate to the root of your project and delete the main.rs file:

   rm main.rs

Run the Project: Navigate to the root directory of your project (where Cargo.toml is located) and run: This should now correctly compile and run your project, displaying the terminal menu as expected.

Ensure src/main.rs is Correct:

cargo run

This should now correctly compile and run your project, displaying the terminal menu as expected.

   Compiling rustmenu v0.1.0 (/home/gbr/Desktop/rustmenu)
error[E0432]: unresolved import `ratatui::text::Spans`
  --> src/main.rs:10:18
   |
10 |     text::{Span, Spans},
   |                  ^^^^^
   |                  |
   |                  no `Spans` in `text`
   |                  help: a similar name exists in the module: `Span`

USED @main.rs

It looks like there was a mistake in the import statement. The correct import should be Lines instead of Spans. Let's correct that. Corrected main.rs

Here is the corrected code for src/main.rs:

main.rs

only output relevant lines

USED @main.rs

Sure, here are the relevant lines with the corrected import:

main.rs

use ratatui::{
    backend::{Backend, CrosstermBackend},
    layout::{Constraint, Direction, Layout},
    style::{Color, Modifier, Style},
    text::Span,
    widgets::{Block, Borders, List, ListItem},
    Terminal,
};

Replace the incorrect import statement with the one above in your src/main.rs file. Then, you can run your project again using:

this code is no different from the original version

USED @main.rs

I apologize for the confusion. The issue is that Spans should be replaced with Span in the ListItem::new call. Here is the corrected code snippet: Corrected run_app Function

fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> io::Result<()> {
    let menu_items = vec!["Item 1", "Item 2", "Item 3"];
    let mut selected = 0;

    loop {
        terminal.draw(|f| {
            let size = f.size();
            let chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Percentage(100)].as_ref())
                .split(size);

            let items: Vec<ListItem> = menu_items
                .iter()
                .enumerate()
                .map(|(i, &item)| {
                    let style = if i == selected {
                        Style::default().bg(Color::Blue)
                    } else {
                        Style::default()
                    };
                    ListItem::new(Span::styled(item, style))
                })
                .collect();

            let list = List::new(items)
                .block(Block::default().borders(Borders::ALL).title("Menu"))
                .highlight_style(Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD));

            f.render_widget(list, chunks[0]);
        })?;

        if let Event::Key(key) = event::read()? {
            match key.code {
                KeyCode::Up => {
                    if selected > 0 {
                        selected -= 1;
                    }
                }
                KeyCode::Down => {
                    if selected < menu_items.len() - 1 {
                        selected += 1;
                    }
                }
                KeyCode::Enter => {
                    match selected {
                        0 => item_1(),
                        1 => item_2(),
                        2 => item_3(),
                        _ => {}
                    }
                }
                KeyCode::Esc => return Ok(()),
                _ => {}
            }
        }
    }
}

Full Code for main.rs

Here is the full corrected code for src/main.rs:

main.rs

use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::{Backend, CrosstermBackend},
    layout::{Constraint, Direction, Layout},
    style::{Color, Modifier, Style},
    text::Span,
    widgets::{Block, Borders, List, ListItem},
    Terminal,
};
use std::io;
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    // Setup terminal
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    // Run the app
    let res = run_app(&mut terminal);

    // Restore terminal
    disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;

    if let Err(err) = res {
        println!("{:?}", err)
    }

    Ok(())
}

fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> io::Result<()> {
    let menu_items = vec!["Item 1", "Item 2", "Item 3"];
    let mut selected = 0;

    loop {
        terminal.draw(|f| {
            let size = f.size();
            let chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Percentage(100)].as_ref())
                .split(size);

            let items: Vec<ListItem> = menu_items
                .iter()
                .enumerate()
                .map(|(i, &item)| {
                    let style = if i == selected {
                        Style::default().bg(Color::Blue)
                    } else {
                        Style::default()
                    };
                    ListItem::new(Span::styled(item, style))
                })
                .collect();

            let list = List::new(items)
                .block(Block::default().borders(Borders::ALL).title("Menu"))
                .highlight_style(Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD));

            f.render_widget(list, chunks[0]);
        })?;

        if let Event::Key(key) = event::read()? {
            match key.code {
                KeyCode::Up => {
                    if selected > 0 {
                        selected -= 1;
                    }
                }
                KeyCode::Down => {
                    if selected < menu_items.len() - 1 {
                        selected += 1;
                    }
                }
                KeyCode::Enter => {
                    match selected {
                        0 => item_1(),
                        1 => item_2(),
                        2 => item_3(),
                        _ => {}
                    }
                }
                KeyCode::Esc => return Ok(()),
                _ => {}
            }
        }
    }
}

fn item_1() {
    println!("Item 1");
}

fn item_2() {
    println!("Item 2");
}

fn item_3() {
    println!("Item 3");
}

This completes the corrected implementation. You should now be able to run your project with cargo run and see the terminal menu with the correct functionality.

I am new tor rust programming and want to setup a learning project. Rust is not installed on my linux machine yet. Should I install rust or rustup? what is the difference?

You should install rustup.
rustup is a toolchain installer for the Rust programming language that makes it easy to install and manage multiple versions of Rust. It allows you to switch between stable, beta, and nightly versions of Rust, and manage different toolchains for different projects.

Here's how you can install rustup on your Linux machine:

  1. Open a terminal.
  2. Run the following command:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
  1. Follow the on-screen instructions to complete the installation.

After installing rustup, you can install the latest stable version of Rust by running:

rustup install stable

This will also install the rustc compiler and the cargo build system, which are essential for Rust development.

I installed the requirements in a nix devbox development shell and have rustup, rustc and cargo availabe

Great! Now that you have rustup, rustc, and cargo available, you can set up a new Rust project. Here are the steps to create a new Rust project: Create a new project: Open your terminal and run:

   cargo new my_rust_project

This will create a new directory called my_rust_project with a basic Rust project structure.

  1. Navigate to the project directory:

    cd my_rust_project
  2. Build the project:

    cargo build
  3. Run the project:

    cargo run

This will build and run your project, displaying "Hello, world!" by default.

Your project structure will look like this:

   my_rust_project/
   ├── Cargo.toml
   └── src/
       └── main.rs

You can edit src/main.rs to start writing your Rust code. Happy coding!

can I cargo new in the current directory which already has files in it?

Yes, you can create a new Rust project in the current directory even if it already contains files. To do this, use the --bin or --lib flag with cargo init instead of cargo new. This will initialize a new Rust project in the existing directory without overwriting any existing files.

Here's how you can do it:

  1. Open your terminal and navigate to the directory where you want to initialize the Rust project.
  2. Run the following command:
   cargo init --bin

or if you want to create a library project:

   cargo init --lib

error: rustup could not choose a version of cargo to run, because one wasn't specified explicitly, and no default is configured. help: run 'rustup default stable' to download the latest stable release of Rust and set it as your default toolchain.

It looks like you need to set the default Rust toolchain. You can do this by running the following command:

   rustup default stable

This command will download the latest stable release of Rust and set it as your default toolchain. After running this command, you should be able to use cargo without any issues.

done. write a hello word rust script that I can run and tell me how to run it

Sure! Here is a simple "Hello, world!" Rust script:

fn main() {
    println!("Hello, world!");
}

To run this script, follow these steps:

  1. Ensure you are in the project directory (if you used cargo init):

    cd path/to/your/project
  2. Build and run the project:

    cargo run

This will compile the project and execute the main function, printing "Hello, world!" to the terminal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment