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;
#[derive(Clone)]
pub struct Choice {
pub title: String,
pub value: String,
pub disabled: bool,
}
impl Choice {
pub fn new(title: impl Into<String>, value: impl Into<String>) -> Self {
Self {
title: title.into(),
value: value.into(),
disabled: false,
}
}
}
pub struct SelectPromptOptions {
pub message: String,
pub choices: Vec<Choice>,
pub initial: Option<usize>,
pub hint: Option<String>,
}
pub(crate) fn next_enabled(choices: &[Choice], current: usize) -> usize {
for i in (current + 1)..choices.len() {
if !choices[i].disabled {
return i;
}
}
current
}
pub(crate) fn prev_enabled(choices: &[Choice], current: usize) -> usize {
for i in (0..current).rev() {
if !choices[i].disabled {
return i;
}
}
current
}
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;
}
let n_lines = opts.choices.len() + 3;
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())
}
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<()> {
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);
}
}