use crossterm::cursor::{MoveToColumn, MoveUp};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, read};
use crossterm::style::Stylize;
use crossterm::terminal::{Clear, ClearType, disable_raw_mode, enable_raw_mode};
use crossterm::{execute, queue};
use std::io::{self, Write};
#[derive(Debug, PartialEq)]
enum Outcome {
Picked(usize),
Cancelled,
}
struct RawGuard;
impl RawGuard {
fn new() -> io::Result<Self> {
enable_raw_mode()?;
Ok(RawGuard)
}
}
impl Drop for RawGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
}
}
fn draw(
out: &mut impl Write,
prompt: &str,
labels: &[String],
selected: usize,
redraw: bool,
) -> io::Result<()> {
if redraw {
queue!(out, MoveUp(labels.len() as u16 + 1))?;
}
queue!(out, MoveToColumn(0), Clear(ClearType::FromCursorDown))?;
write!(out, "{prompt}\r\n")?;
for (i, label) in labels.iter().enumerate() {
if i == selected {
write!(out, "{}\r\n", format!("> {}. {label}", i + 1).bold())?;
} else {
write!(out, " {}. {label}\r\n", i + 1)?;
}
}
out.flush()
}
pub fn pick(prompt: &str, labels: &[String]) -> io::Result<Option<usize>> {
let mut out = io::stdout();
let guard = RawGuard::new()?;
let mut selected = 0usize;
draw(&mut out, prompt, labels, selected, false)?;
let outcome = loop {
if let Event::Key(KeyEvent {
code,
modifiers,
kind,
..
}) = read()?
{
if kind != KeyEventKind::Press {
continue;
}
let (next, outcome) = step(selected, labels.len(), code, modifiers);
if next != selected {
selected = next;
draw(&mut out, prompt, labels, selected, true)?;
}
if let Some(outcome) = outcome {
break outcome;
}
}
};
execute!(
out,
MoveUp(labels.len() as u16 + 1),
MoveToColumn(0),
Clear(ClearType::FromCursorDown)
)?;
drop(guard);
match outcome {
Outcome::Picked(idx) => {
println!("→ {}", labels[idx]);
Ok(Some(idx))
}
Outcome::Cancelled => Ok(None),
}
}
fn step(
selected: usize,
len: usize,
code: KeyCode,
modifiers: KeyModifiers,
) -> (usize, Option<Outcome>) {
match code {
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
(selected, Some(Outcome::Cancelled))
}
KeyCode::Esc | KeyCode::Char('q') => (selected, Some(Outcome::Cancelled)),
KeyCode::Up | KeyCode::Char('k') => (selected.saturating_sub(1), None),
KeyCode::Down | KeyCode::Char('j') => ((selected + 1).min(len - 1), None),
KeyCode::Enter => (selected, Some(Outcome::Picked(selected))),
KeyCode::Char(c @ '1'..='9') => {
let idx = (c as usize) - ('1' as usize);
if idx < len {
(idx, Some(Outcome::Picked(idx)))
} else {
(selected, None)
}
}
_ => (selected, None),
}
}
#[cfg(test)]
mod tests {
use super::*;
const NONE: KeyModifiers = KeyModifiers::NONE;
#[test]
fn picker_up_clamps_at_top() {
assert_eq!(step(0, 3, KeyCode::Up, NONE), (0, None));
assert_eq!(step(2, 3, KeyCode::Up, NONE), (1, None));
}
#[test]
fn picker_down_clamps_at_bottom() {
assert_eq!(step(2, 3, KeyCode::Down, NONE), (2, None));
assert_eq!(step(0, 3, KeyCode::Down, NONE), (1, None));
}
#[test]
fn picker_vim_keys_move() {
assert_eq!(step(1, 3, KeyCode::Char('k'), NONE), (0, None));
assert_eq!(step(1, 3, KeyCode::Char('j'), NONE), (2, None));
}
#[test]
fn picker_enter_picks_selected() {
assert_eq!(
step(1, 3, KeyCode::Enter, NONE),
(1, Some(Outcome::Picked(1)))
);
}
#[test]
fn picker_digit_jump_selects_in_range() {
assert_eq!(
step(0, 3, KeyCode::Char('2'), NONE),
(1, Some(Outcome::Picked(1)))
);
}
#[test]
fn picker_digit_out_of_range_is_ignored() {
assert_eq!(step(0, 3, KeyCode::Char('9'), NONE), (0, None));
}
#[test]
fn picker_esc_q_and_ctrl_c_cancel() {
assert_eq!(
step(1, 3, KeyCode::Esc, NONE),
(1, Some(Outcome::Cancelled))
);
assert_eq!(
step(1, 3, KeyCode::Char('q'), NONE),
(1, Some(Outcome::Cancelled))
);
assert_eq!(
step(1, 3, KeyCode::Char('c'), KeyModifiers::CONTROL),
(1, Some(Outcome::Cancelled))
);
}
#[test]
fn picker_unknown_keys_do_nothing() {
assert_eq!(step(1, 3, KeyCode::Char('x'), NONE), (1, None));
}
}