use std::io;
use std::sync::mpsc;
use std::time::{Duration, Instant};
use console::Key;
#[derive(Debug, Clone)]
pub struct Select<'a> {
pub title: &'a str,
pub question: &'a str,
pub lines: &'a [&'a str],
pub yes: &'a str,
pub no: &'a str,
pub default_yes: bool,
pub timeout: Option<Duration>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SelectOutcome {
Answered(bool),
Eof,
ReadError(ReadFailure),
TimedOut,
Unrecognized,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReadFailure {
pub kind: io::ErrorKind,
pub os_code: Option<i32>,
}
impl From<&io::Error> for ReadFailure {
fn from(e: &io::Error) -> Self {
Self {
kind: e.kind(),
os_code: e.raw_os_error(),
}
}
}
const MAX_LINE_ATTEMPTS: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LineRead {
Line(String),
Eof,
Error(ReadFailure),
TimedOut,
}
pub fn parse_answer(answer: &str, default_yes: bool) -> Option<bool> {
match answer.trim().to_ascii_lowercase().as_str() {
"" => Some(default_yes),
"y" | "yes" => Some(true),
"n" | "no" => Some(false),
_ => None,
}
}
pub fn ask_line(
default_yes: bool,
timeout: Option<Duration>,
mut read: impl FnMut(Option<Duration>) -> LineRead,
mut reprompt: impl FnMut(),
) -> SelectOutcome {
let deadline = timeout.map(|t| Instant::now() + t);
for attempt in 1..=MAX_LINE_ATTEMPTS {
let left = deadline.map(|d| d.saturating_duration_since(Instant::now()));
match read(left) {
LineRead::Line(line) => {
if let Some(yes) = parse_answer(&line, default_yes) {
return SelectOutcome::Answered(yes);
}
if attempt < MAX_LINE_ATTEMPTS {
reprompt();
}
}
LineRead::Eof => return SelectOutcome::Eof,
LineRead::Error(failure) => return SelectOutcome::ReadError(failure),
LineRead::TimedOut => return SelectOutcome::TimedOut,
}
}
SelectOutcome::Unrecognized
}
pub fn read_line_within<F>(timeout: Option<Duration>, read_line: F) -> LineRead
where
F: FnOnce() -> io::Result<(usize, String)> + Send + 'static,
{
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(read_line());
});
let got = match timeout {
Some(t) => rx.recv_timeout(t).map_err(|_| ()),
None => rx.recv().map_err(|_| ()),
};
match got {
Ok(Ok((0, _))) => LineRead::Eof,
Ok(Ok((_, line))) => LineRead::Line(line),
Ok(Err(e)) => LineRead::Error(ReadFailure::from(&e)),
Err(()) => LineRead::TimedOut,
}
}
pub fn read_stdin_line(timeout: Option<Duration>) -> LineRead {
read_line_within(timeout, || {
let mut line = String::new();
let n = io::stdin().read_line(&mut line)?;
Ok((n, line))
})
}
pub fn discard_typeahead() {
#[cfg(windows)]
{
use winapi::um::consoleapi::GetConsoleMode;
use winapi::um::processenv::GetStdHandle;
use winapi::um::winbase::STD_INPUT_HANDLE;
use winapi::um::wincon::FlushConsoleInputBuffer;
unsafe {
let handle = GetStdHandle(STD_INPUT_HANDLE);
let mut mode = 0;
if GetConsoleMode(handle, &mut mode) != 0 {
FlushConsoleInputBuffer(handle);
}
}
}
#[cfg(unix)]
{
unsafe {
if libc::isatty(libc::STDIN_FILENO) == 1 {
libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH);
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeyState {
pub yes: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyStep {
Moved,
Answer(bool),
Eof,
Ignore,
}
impl KeyState {
pub fn apply(&mut self, key: &Key) -> KeyStep {
match key {
Key::ArrowLeft | Key::ArrowRight | Key::Tab | Key::BackTab => {
self.yes = !self.yes;
KeyStep::Moved
}
Key::ArrowUp | Key::Home => self.go(true),
Key::ArrowDown | Key::End => self.go(false),
Key::Enter => KeyStep::Answer(self.yes),
Key::Char('y' | 'Y') => KeyStep::Answer(true),
Key::Char('n' | 'N') => KeyStep::Answer(false),
Key::Char('\u{4}') => KeyStep::Eof,
_ => KeyStep::Ignore,
}
}
fn go(&mut self, yes: bool) -> KeyStep {
if self.yes == yes {
KeyStep::Ignore
} else {
self.yes = yes;
KeyStep::Moved
}
}
}
const MAX_UNKNOWN_KEYS: usize = 64;
pub fn ask_keys(
default_yes: bool,
mut read_key: impl FnMut() -> io::Result<Key>,
mut redraw: impl FnMut(KeyState),
) -> SelectOutcome {
let mut state = KeyState { yes: default_yes };
let mut unknown = 0;
loop {
let key = match read_key() {
Ok(k) => k,
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return SelectOutcome::Eof,
Err(e) => return SelectOutcome::ReadError(ReadFailure::from(&e)),
};
if key == Key::Unknown {
unknown += 1;
if unknown >= MAX_UNKNOWN_KEYS {
return SelectOutcome::Eof;
}
continue;
}
unknown = 0;
match state.apply(&key) {
KeyStep::Moved => redraw(state),
KeyStep::Answer(yes) => return SelectOutcome::Answered(yes),
KeyStep::Eof => return SelectOutcome::Eof,
KeyStep::Ignore => {}
}
}
}
#[derive(Debug, Clone)]
pub struct SelectList<'a> {
pub title: &'a str,
pub question: &'a str,
pub lines: &'a [&'a str],
pub items: Vec<(String, String)>,
pub timeout: Option<Duration>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListOutcome {
Answered(usize),
Eof,
ReadError(ReadFailure),
TimedOut,
Unrecognized,
}
pub fn parse_list_answer(answer: &str, len: usize) -> Option<usize> {
let n: usize = answer.trim().parse().ok()?;
(1..=len).contains(&n).then(|| n - 1)
}
pub fn ask_list_line(
len: usize,
timeout: Option<Duration>,
mut read: impl FnMut(Option<Duration>) -> LineRead,
mut reprompt: impl FnMut(),
) -> ListOutcome {
let deadline = timeout.map(|t| Instant::now() + t);
for attempt in 1..=MAX_LINE_ATTEMPTS {
let left = deadline.map(|d| d.saturating_duration_since(Instant::now()));
match read(left) {
LineRead::Line(line) => {
if let Some(index) = parse_list_answer(&line, len) {
return ListOutcome::Answered(index);
}
if attempt < MAX_LINE_ATTEMPTS {
reprompt();
}
}
LineRead::Eof => return ListOutcome::Eof,
LineRead::Error(failure) => return ListOutcome::ReadError(failure),
LineRead::TimedOut => return ListOutcome::TimedOut,
}
}
ListOutcome::Unrecognized
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ListKeyState {
pub marked: Option<usize>,
pub len: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListStep {
Moved,
Answer(usize),
Eof,
Ignore,
}
impl ListKeyState {
pub fn new(len: usize) -> Self {
Self { marked: None, len }
}
pub fn apply(&mut self, key: &Key) -> ListStep {
if *key == Key::Char('\u{4}') {
return ListStep::Eof;
}
let Some(last) = self.len.checked_sub(1) else {
return ListStep::Ignore;
};
match key {
Key::ArrowDown => self.mark(self.marked.map_or(0, |i| (i + 1).min(last))),
Key::ArrowUp => self.mark(self.marked.map_or(last, |i| i.saturating_sub(1))),
Key::Home => self.mark(0),
Key::End => self.mark(last),
Key::Char(c @ '1'..='9') => {
let index = (*c as usize) - ('1' as usize);
if index < self.len {
self.mark(index)
} else {
ListStep::Ignore
}
}
Key::Enter => self.marked.map_or(ListStep::Ignore, ListStep::Answer),
_ => ListStep::Ignore,
}
}
fn mark(&mut self, index: usize) -> ListStep {
if self.marked == Some(index) {
ListStep::Ignore
} else {
self.marked = Some(index);
ListStep::Moved
}
}
}
pub fn ask_list_keys(
len: usize,
mut read_key: impl FnMut() -> io::Result<Key>,
mut redraw: impl FnMut(ListKeyState),
) -> ListOutcome {
let mut state = ListKeyState::new(len);
let mut unknown = 0;
loop {
let key = match read_key() {
Ok(k) => k,
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return ListOutcome::Eof,
Err(e) => return ListOutcome::ReadError(ReadFailure::from(&e)),
};
if key == Key::Unknown {
unknown += 1;
if unknown >= MAX_UNKNOWN_KEYS {
return ListOutcome::Eof;
}
continue;
}
unknown = 0;
match state.apply(&key) {
ListStep::Moved => redraw(state),
ListStep::Answer(index) => return ListOutcome::Answered(index),
ListStep::Eof => return ListOutcome::Eof,
ListStep::Ignore => {}
}
}
}
pub fn fit_rows(items: &[(String, String)], room: usize, unicode: bool) -> Vec<String> {
let tail = if unicode { "…" } else { "..." };
let width = console::measure_text_width;
let widest_tag = items.iter().map(|(_, t)| width(t)).max().unwrap_or(0);
let label_room = room.saturating_sub(widest_tag + 2).max(1);
let labels: Vec<String> = items
.iter()
.map(|(l, _)| {
if width(l) <= label_room {
l.clone()
} else {
console::truncate_str(l, label_room, tail).into_owned()
}
})
.collect();
let column = labels.iter().map(|l| width(l)).max().unwrap_or(0);
labels
.iter()
.zip(items)
.map(|(label, (_, tag))| {
let pad = " ".repeat(column - width(label));
format!("{label}{pad} {tag}")
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn select_list_opens_with_no_row_marked_and_enter_does_nothing() {
let mut state = ListKeyState::new(2);
assert_eq!(state.marked, None);
assert_eq!(state.apply(&Key::Enter), ListStep::Ignore);
assert_eq!(state.marked, None);
}
#[test]
fn select_list_arrows_home_end_and_digits_mark_rows() {
let mut s = ListKeyState::new(3);
assert_eq!(s.apply(&Key::ArrowDown), ListStep::Moved);
assert_eq!(s.marked, Some(0));
assert_eq!(s.apply(&Key::ArrowDown), ListStep::Moved);
assert_eq!(s.apply(&Key::ArrowDown), ListStep::Moved);
assert_eq!(s.marked, Some(2));
assert_eq!(
s.apply(&Key::ArrowDown),
ListStep::Ignore,
"stops at the end"
);
assert_eq!(s.apply(&Key::Home), ListStep::Moved);
assert_eq!(s.marked, Some(0));
assert_eq!(s.apply(&Key::ArrowUp), ListStep::Ignore, "stops at the top");
assert_eq!(s.apply(&Key::End), ListStep::Moved);
assert_eq!(s.marked, Some(2));
assert_eq!(s.apply(&Key::Char('2')), ListStep::Moved);
assert_eq!(s.marked, Some(1));
assert_eq!(s.apply(&Key::Char('4')), ListStep::Ignore, "no fourth row");
assert_eq!(s.apply(&Key::Char('0')), ListStep::Ignore);
assert_eq!(s.apply(&Key::Enter), ListStep::Answer(1));
let mut up_first = ListKeyState::new(3);
up_first.apply(&Key::ArrowUp);
assert_eq!(
up_first.marked,
Some(2),
"up from nothing marks the last row"
);
}
#[test]
fn select_list_key_outcomes() {
let ask = |len, seq| ask_list_keys(len, keys(seq), |_| {});
assert_eq!(
ask(2, vec![Ok(Key::Enter), Ok(Key::Char('2')), Ok(Key::Enter)]),
ListOutcome::Answered(1)
);
assert_eq!(
ask(2, vec![Ok(Key::Enter), Ok(Key::Char('\u{4}'))]),
ListOutcome::Eof,
"Enter on nothing, then ^D: never a default"
);
assert_eq!(
ask(2, vec![Err(io::Error::from(io::ErrorKind::UnexpectedEof))]),
ListOutcome::Eof
);
assert_eq!(
ask(2, vec![Err(io::Error::from(io::ErrorKind::Other))]),
ListOutcome::ReadError(failed(io::ErrorKind::Other))
);
let unknowns = (0..MAX_UNKNOWN_KEYS).map(|_| Ok(Key::Unknown)).collect();
assert_eq!(ask(2, unknowns), ListOutcome::Eof);
}
#[test]
fn select_list_redraws_only_on_a_move() {
let mut draws = Vec::new();
let outcome = ask_list_keys(
2,
keys(vec![
Ok(Key::Enter),
Ok(Key::ArrowDown),
Ok(Key::Char('1')),
Ok(Key::Enter),
]),
|s| draws.push(s.marked),
);
assert_eq!(outcome, ListOutcome::Answered(0));
assert_eq!(draws, vec![Some(0)]);
}
#[test]
fn select_list_line_answers() {
assert_eq!(parse_list_answer("2\n", 3), Some(1));
assert_eq!(parse_list_answer(" 1 ", 3), Some(0));
assert_eq!(parse_list_answer("", 3), None, "an empty line is no choice");
assert_eq!(parse_list_answer("0", 3), None);
assert_eq!(parse_list_answer("4", 3), None);
assert_eq!(parse_list_answer("openlatch", 3), None);
}
#[test]
fn select_list_line_path_gives_three_attempts() {
let ask = |reads| ask_list_line(2, None, scripted(reads), || {});
assert_eq!(
ask(vec![
LineRead::Line("\n".into()),
LineRead::Line("2\n".into())
]),
ListOutcome::Answered(1)
);
assert_eq!(
ask(vec![
LineRead::Line("".into()),
LineRead::Line("x".into()),
LineRead::Line("9".into())
]),
ListOutcome::Unrecognized
);
assert_eq!(ask(vec![LineRead::Eof]), ListOutcome::Eof);
assert_eq!(ask(vec![LineRead::TimedOut]), ListOutcome::TimedOut);
assert_eq!(
ask(vec![LineRead::Error(failed(io::ErrorKind::InvalidData))]),
ListOutcome::ReadError(failed(io::ErrorKind::InvalidData))
);
let mut reprompts = 0;
ask_list_line(
2,
None,
scripted(vec![LineRead::Line("?".into()), LineRead::Line("1".into())]),
|| reprompts += 1,
);
assert_eq!(reprompts, 1);
}
#[test]
fn select_list_rows_cut_the_label_never_the_tag() {
let items = vec![
("OpenLatch".to_string(), "openlatch".to_string()),
(
"A very long organization name indeed".to_string(),
"long-org".to_string(),
),
];
let wide = fit_rows(&items, 80, true);
assert_eq!(wide[0], format!("OpenLatch{} openlatch", " ".repeat(27)));
assert!(wide[1].ends_with(" long-org"));
let narrow = fit_rows(&items, 24, true);
for row in &narrow {
assert!(console::measure_text_width(row) <= 24, "{row}");
}
assert!(narrow[0].ends_with("openlatch"));
assert!(narrow[1].ends_with("long-org"));
assert!(narrow[1].contains('…'), "{}", narrow[1]);
assert!(fit_rows(&items, 24, false)[1].contains("..."));
}
fn failed(kind: io::ErrorKind) -> ReadFailure {
ReadFailure::from(&io::Error::from(kind))
}
#[test]
fn test_parse_answer() {
assert_eq!(parse_answer("", true), Some(true));
assert_eq!(parse_answer("\r\n", false), Some(false));
assert_eq!(parse_answer("Y\r\n", false), Some(true));
assert_eq!(parse_answer(" yes ", false), Some(true));
assert_eq!(parse_answer("N", true), Some(false));
assert_eq!(parse_answer("No\n", true), Some(false));
assert_eq!(parse_answer("maybe", true), None);
}
fn scripted(reads: Vec<LineRead>) -> impl FnMut(Option<Duration>) -> LineRead {
let mut reads = reads.into_iter();
move |_| reads.next().expect("no more scripted reads")
}
#[test]
fn test_line_classifier() {
let ask = |reads| ask_line(false, None, scripted(reads), || {});
assert_eq!(
ask(vec![LineRead::Line("Y\r\n".into())]),
SelectOutcome::Answered(true)
);
assert_eq!(ask(vec![LineRead::Eof]), SelectOutcome::Eof);
assert_eq!(
ask(vec![LineRead::Error(failed(io::ErrorKind::InvalidData))]),
SelectOutcome::ReadError(failed(io::ErrorKind::InvalidData))
);
assert_eq!(ask(vec![LineRead::TimedOut]), SelectOutcome::TimedOut);
assert_eq!(
ask(vec![
LineRead::Line("what".into()),
LineRead::Line("n\n".into())
]),
SelectOutcome::Answered(false)
);
assert_eq!(
ask(vec![
LineRead::Line("a".into()),
LineRead::Line("b".into()),
LineRead::Line("c".into())
]),
SelectOutcome::Unrecognized
);
}
#[test]
fn test_line_reprompts_between_unrecognized_answers() {
let mut reprompts = 0;
let outcome = ask_line(
true,
None,
scripted(vec![LineRead::Line("?".into()), LineRead::Line("".into())]),
|| reprompts += 1,
);
assert_eq!(outcome, SelectOutcome::Answered(true));
assert_eq!(reprompts, 1);
}
#[test]
fn test_read_line_within_distinguishes_all_four() {
let t = Some(Duration::from_secs(5));
assert_eq!(
read_line_within(t, || Ok((2, "y\n".into()))),
LineRead::Line("y\n".into())
);
assert_eq!(
read_line_within(t, || Ok((0, String::new()))),
LineRead::Eof
);
assert_eq!(
read_line_within(t, || Err(io::Error::from(io::ErrorKind::InvalidData))),
LineRead::Error(failed(io::ErrorKind::InvalidData))
);
assert_eq!(
read_line_within(Some(Duration::from_millis(20)), || {
std::thread::sleep(Duration::from_secs(2));
Ok((2, "y\n".into()))
}),
LineRead::TimedOut
);
}
fn keys(seq: Vec<io::Result<Key>>) -> impl FnMut() -> io::Result<Key> {
let mut seq = seq.into_iter();
move || seq.next().expect("no more scripted keys")
}
#[test]
fn test_key_classifier() {
let ask = |default, seq| ask_keys(default, keys(seq), |_| {});
assert_eq!(
ask(true, vec![Ok(Key::Enter)]),
SelectOutcome::Answered(true)
);
assert_eq!(
ask(true, vec![Ok(Key::ArrowRight), Ok(Key::Enter)]),
SelectOutcome::Answered(false)
);
assert_eq!(
ask(false, vec![Ok(Key::Tab), Ok(Key::Tab), Ok(Key::Enter)]),
SelectOutcome::Answered(false)
);
assert_eq!(
ask(false, vec![Ok(Key::Char('Y'))]),
SelectOutcome::Answered(true)
);
assert_eq!(
ask(true, vec![Ok(Key::Char('n'))]),
SelectOutcome::Answered(false)
);
assert_eq!(ask(true, vec![Ok(Key::Char('\u{4}'))]), SelectOutcome::Eof);
assert_eq!(
ask(
true,
vec![Err(io::Error::from(io::ErrorKind::UnexpectedEof))]
),
SelectOutcome::Eof
);
assert_eq!(
ask(true, vec![Err(io::Error::from(io::ErrorKind::Other))]),
SelectOutcome::ReadError(failed(io::ErrorKind::Other))
);
let unknowns = (0..MAX_UNKNOWN_KEYS).map(|_| Ok(Key::Unknown)).collect();
assert_eq!(ask(true, unknowns), SelectOutcome::Eof);
}
#[test]
fn test_read_failure_keeps_the_os_code() {
let failure = ReadFailure::from(&io::Error::from_raw_os_error(6));
assert_eq!(failure.os_code, Some(6));
assert_eq!(
read_line_within(None, || Err(io::Error::from_raw_os_error(6))),
LineRead::Error(failure)
);
}
#[test]
fn test_key_redraw_only_on_move() {
let mut draws = Vec::new();
let outcome = ask_keys(
true,
keys(vec![
Ok(Key::ArrowUp),
Ok(Key::Char('x')),
Ok(Key::ArrowDown),
Ok(Key::Enter),
]),
|s| draws.push(s.yes),
);
assert_eq!(outcome, SelectOutcome::Answered(false));
assert_eq!(draws, vec![false], "only ArrowDown moved the highlight");
}
}