promptt 1.1.0

Interactive CLI prompts library, lightweight and easy to use.
Documentation
//! Select prompt.

use crate::util::figures::Figures;
use crate::util::style;
use ansi_escapes::{EraseLine, EraseLines};
use colour::{write_bold, write_cyan, write_gray};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use std::io::{self, BufRead, Write};
use strip_ansi_escapes::strip_str;

/// Single choice option.
#[derive(Clone)]
pub struct Choice {
    pub title: String,
    pub value: String,
    pub disabled: bool,
}

impl Choice {
    /// Builds a choice with the given title and value; disabled defaults to false.
    pub fn new(title: impl Into<String>, value: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            value: value.into(),
            disabled: false,
        }
    }
}

/// Select prompt options.
pub struct SelectPromptOptions {
    pub message: String,
    pub choices: Vec<Choice>,
    pub initial: Option<usize>,
    pub hint: Option<String>,
}

/// Returns next enabled index when moving down, or same if none.
pub(crate) fn next_enabled(choices: &[Choice], current: usize) -> usize {
    for i in (current + 1)..choices.len() {
        if !choices[i].disabled {
            return i;
        }
    }
    current
}

/// Returns previous enabled index when moving up, or same if none.
pub(crate) fn prev_enabled(choices: &[Choice], current: usize) -> usize {
    for i in (0..current).rev() {
        if !choices[i].disabled {
            return i;
        }
    }
    current
}

/// Runs select prompt. Returns value of selected choice.
pub fn run_select<R: BufRead, W: Write>(
    opts: &SelectPromptOptions,
    stdin: &mut R,
    stdout: &mut W,
) -> io::Result<String> {
    let fig = Figures::default();
    let mut buf = Vec::with_capacity(opts.message.len() + 32);
    write_bold!(&mut buf, "{}", opts.message).ok();
    let msg = String::from_utf8_lossy(&buf).into_owned();
    let delim = style::delimiter(false);
    let hint = opts
        .hint
        .as_deref()
        .unwrap_or("Use ↑/↓ to select. Return to submit.");
    let mut gray_buf = Vec::with_capacity(hint.len() + 16);
    write_gray!(&mut gray_buf, "{}", hint).ok();
    let hint_styled = String::from_utf8_lossy(&gray_buf).into_owned();

    let mut selected = opts.initial.unwrap_or(0);
    if selected >= opts.choices.len() {
        selected = 0;
    }
    while opts
        .choices
        .get(selected)
        .map(|c| c.disabled)
        .unwrap_or(true)
    {
        let next = next_enabled(&opts.choices, selected);
        if next == selected {
            break;
        }
        selected = next;
    }

    // Cursor ends one line below hint; EraseLines(n) erases current + n-1 above
    let n_lines = opts.choices.len() + 3; // message, choices, hint, cursor line

    let run_interactive = std::io::IsTerminal::is_terminal(&std::io::stdin());

    if run_interactive {
        let _guard = crossterm::terminal::enable_raw_mode()
            .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;

        let result = run_select_interactive(
            opts,
            stdout,
            &fig,
            &msg,
            &delim,
            &hint_styled,
            &mut selected,
            n_lines,
        );

        crossterm::terminal::disable_raw_mode()
            .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;

        if let Err(e) = result {
            return Err(e);
        }
    } else {
        writeln!(stdout, "{} {}", msg, delim)?;
        for (i, c) in opts.choices.iter().enumerate() {
            let prefix = if c.disabled { " " } else { fig.pointer_small };
            let mut line_buf = Vec::new();
            write_cyan!(&mut line_buf, " {} ", (i + 1)).ok();
            let num = String::from_utf8_lossy(&line_buf).into_owned();
            let line = format!("  {} {} {}", num, prefix, c.title);
            writeln!(stdout, "{}", line)?;
        }
        writeln!(stdout, "  {}", hint_styled)?;
        write!(stdout, "  Answer (1-{}): ", opts.choices.len())?;
        stdout.flush()?;
        let mut line = String::new();
        stdin.read_line(&mut line)?;
        selected = parse_selection(opts, &strip_str(line.trim()))?;
    }

    let choice = opts
        .choices
        .get(selected)
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid choice"))?;
    if choice.disabled {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "selected option is disabled",
        ));
    }
    let done_delim = style::delimiter(true);
    write!(
        stdout,
        "\r{} {} {}{}",
        msg, done_delim, choice.title, EraseLine
    )?;
    writeln!(stdout)?;
    stdout.flush()?;
    Ok(choice.value.clone())
}

/// Parse 1-based number into choice index. For non-interactive (pipe) mode only.
pub(crate) fn parse_selection(opts: &SelectPromptOptions, raw: &str) -> io::Result<usize> {
    let idx = raw
        .parse::<usize>()
        .ok()
        .filter(|n| (1..=opts.choices.len()).contains(n))
        .map(|n| n - 1);
    Ok(idx.or(opts.initial).unwrap_or(0))
}

fn run_select_interactive<W: Write>(
    opts: &SelectPromptOptions,
    stdout: &mut W,
    fig: &Figures,
    msg: &str,
    delim: &str,
    hint_styled: &str,
    selected: &mut usize,
    n_lines: usize,
) -> io::Result<()> {
    // In raw mode, \n alone does not move to column 0; use \r\n so each line starts at column 0.
    const NL: &str = "\r\n";

    fn write_choices(
        opts: &SelectPromptOptions,
        fig: &Figures,
        selected: usize,
        stdout: &mut dyn Write,
        nl: &str,
    ) -> io::Result<()> {
        for (i, c) in opts.choices.iter().enumerate() {
            let prefix = if c.disabled {
                " "
            } else if i == selected {
                fig.pointer_small
            } else {
                " "
            };
            let mut line_buf = Vec::new();
            write_cyan!(&mut line_buf, " {} ", (i + 1)).ok();
            let num = String::from_utf8_lossy(&line_buf).into_owned();
            write!(stdout, "  {} {} {}{}", num, prefix, c.title, nl)?;
        }
        Ok(())
    }

    write!(stdout, "{} {}{}", msg, delim, NL)?;
    write_choices(opts, fig, *selected, stdout, NL)?;
    write!(stdout, "  {}{}", hint_styled, NL)?;
    stdout.flush()?;

    let n_lines_u16 = n_lines as u16;
    loop {
        let ev = event::read().map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
        let (should_break, should_redraw) = match ev {
            Event::Key(e) if e.kind == KeyEventKind::Press => {
                if e.modifiers.contains(KeyModifiers::CONTROL)
                    && matches!(e.code, KeyCode::Char('c') | KeyCode::Char('C'))
                {
                    return Err(io::Error::new(
                        io::ErrorKind::Interrupted,
                        "user cancelled (Ctrl+C)",
                    ));
                }
                match e.code {
                    KeyCode::Enter | KeyCode::Char('\n' | '\r') => (true, false),
                    KeyCode::Up => {
                        *selected = prev_enabled(&opts.choices, *selected);
                        (false, true)
                    }
                    KeyCode::Down => {
                        *selected = next_enabled(&opts.choices, *selected);
                        (false, true)
                    }
                    _ => (false, false),
                }
            }
            _ => (false, false),
        };
        if should_break {
            break;
        }
        if should_redraw {
            write!(stdout, "{}", EraseLines(n_lines_u16))?;
            write!(stdout, "{} {}{}", msg, delim, NL)?;
            write_choices(opts, fig, *selected, stdout, NL)?;
            write!(stdout, "  {}{}", hint_styled, NL)?;
            stdout.flush()?;
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn choice_new() {
        let c = Choice::new("Title", "value");
        assert_eq!(c.title, "Title");
        assert_eq!(c.value, "value");
        assert!(!c.disabled);
    }

    #[test]
    fn next_enabled_skips_disabled() {
        let a = Choice::new("A", "a");
        let mut b = Choice::new("B", "b");
        b.disabled = true;
        let c = Choice::new("C", "c");
        let choices = vec![a, b, c];
        assert_eq!(next_enabled(&choices, 0), 2);
        assert_eq!(next_enabled(&choices, 2), 2);
    }

    #[test]
    fn prev_enabled_skips_disabled() {
        let a = Choice::new("A", "a");
        let mut b = Choice::new("B", "b");
        b.disabled = true;
        let c = Choice::new("C", "c");
        let choices = vec![a, b, c];
        assert_eq!(prev_enabled(&choices, 2), 0);
        assert_eq!(prev_enabled(&choices, 0), 0);
    }
}