diff --git a/Cargo.toml b/Cargo.toml index 9877f4f..3adc917 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [dependencies] +ahash = "0.7.2" crossterm = "0.19.0" -fxhash = "0.2.1" tempfile = "3" ui = { path = "./ui" } diff --git a/src/answer.rs b/src/answer.rs index 64f879d..b18b697 100644 --- a/src/answer.rs +++ b/src/answer.rs @@ -1,6 +1,12 @@ -use fxhash::FxHashSet as HashSet; +use std::{ + collections::hash_map::{Entry, IntoIter}, + hash::Hash, + ops::{Deref, DerefMut}, +}; -#[derive(Debug, Clone)] +use ahash::AHashMap as HashMap; + +#[derive(Debug, Clone, PartialEq, PartialOrd)] pub enum Answer { String(String), ListItem(ListItem), @@ -8,17 +14,172 @@ pub enum Answer { Int(i64), Float(f64), Bool(bool), - ListItems(HashSet), + ListItems(Vec), } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +impl Answer { + /// Returns `true` if the answer is [`String`]. + pub fn is_string(&self) -> bool { + matches!(self, Self::String(..)) + } + + pub fn try_into_string(self) -> Result { + match self { + Self::String(v) => Ok(v), + _ => Err(self), + } + } + + /// Returns `true` if the answer is [`ListItem`]. + pub fn is_list_item(&self) -> bool { + matches!(self, Self::ListItem(..)) + } + + pub fn try_into_list_item(self) -> Result { + match self { + Self::ListItem(v) => Ok(v), + _ => Err(self), + } + } + + /// Returns `true` if the answer is [`ExpandItem`]. + pub fn is_expand_item(&self) -> bool { + matches!(self, Self::ExpandItem(..)) + } + + pub fn try_into_expand_item(self) -> Result { + match self { + Self::ExpandItem(v) => Ok(v), + _ => Err(self), + } + } + + /// Returns `true` if the answer is [`Int`]. + pub fn is_int(&self) -> bool { + matches!(self, Self::Int(..)) + } + + pub fn try_into_int(self) -> Result { + match self { + Self::Int(v) => Ok(v), + _ => Err(self), + } + } + + /// Returns `true` if the answer is [`Float`]. + pub fn is_float(&self) -> bool { + matches!(self, Self::Float(..)) + } + + pub fn try_into_float(self) -> Result { + match self { + Self::Float(v) => Ok(v), + _ => Err(self), + } + } + + /// Returns `true` if the answer is [`Bool`]. + pub fn is_bool(&self) -> bool { + matches!(self, Self::Bool(..)) + } + + pub fn try_into_bool(self) -> Result { + match self { + Self::Bool(v) => Ok(v), + _ => Err(self), + } + } + + /// Returns `true` if the answer is [`ListItems`]. + pub fn is_list_items(&self) -> bool { + matches!(self, Self::ListItems(..)) + } + + pub fn try_into_list_items(self) -> Result, Self> { + match self { + Self::ListItems(v) => Ok(v), + _ => Err(self), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ListItem { pub index: usize, pub name: String, } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +impl From<(usize, String)> for ListItem { + fn from((index, name): (usize, String)) -> Self { + Self { index, name } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ExpandItem { pub key: char, pub name: String, } + +impl From<(char, String)> for ExpandItem { + fn from((key, name): (char, String)) -> Self { + Self { key, name } + } +} + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Answers { + answers: HashMap, +} + +impl Answers { + pub(crate) fn insert(&mut self, name: String, answer: Answer) -> &mut Answer { + match self.answers.entry(name) { + Entry::Occupied(entry) => { + let entry = entry.into_mut(); + *entry = answer; + entry + } + Entry::Vacant(entry) => entry.insert(answer), + } + } +} + +impl Extend<(String, Answer)> for Answers { + fn extend>(&mut self, iter: T) { + self.answers.extend(iter) + } + + #[cfg(nightly)] + fn extend_one(&mut self, item: (String, Answer)) { + self.answers.extend_one(item); + } + + #[cfg(nightly)] + fn extend_reserve(&mut self, additional: usize) { + self.answers.extend_reserve(additional) + } +} + +impl Deref for Answers { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.answers + } +} + +impl DerefMut for Answers { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.answers + } +} + +impl IntoIterator for Answers { + type Item = (String, Answer); + type IntoIter = IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.answers.into_iter() + } +} diff --git a/src/error.rs b/src/error.rs index 678d32e..a6ace9c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -3,11 +3,13 @@ use std::{fmt, io}; pub type Result = std::result::Result; #[derive(Debug)] +#[non_exhaustive] pub enum InquirerError { IoError(io::Error), FmtError(fmt::Error), Utf8Error(std::string::FromUtf8Error), ParseIntError(std::num::ParseIntError), + NotATty, } impl std::error::Error for InquirerError { @@ -17,16 +19,19 @@ impl std::error::Error for InquirerError { InquirerError::FmtError(e) => Some(e), InquirerError::Utf8Error(e) => Some(e), InquirerError::ParseIntError(e) => Some(e), + InquirerError::NotATty => None, } } } -// TODO: better display impl impl fmt::Display for InquirerError { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { - match *self { - InquirerError::IoError(_) => write!(fmt, "IO-error occurred"), - _ => write!(fmt, "Some error has occurred"), + match self { + InquirerError::IoError(e) => write!(fmt, "IoError: {}", e), + InquirerError::FmtError(e) => write!(fmt, "FmtError: {}", e), + InquirerError::Utf8Error(e) => write!(fmt, "Utf8Error: {}", e), + InquirerError::ParseIntError(e) => write!(fmt, "ParseIntError: {}", e), + InquirerError::NotATty => write!(fmt, "Not a tty"), } } } diff --git a/src/lib.rs b/src/lib.rs index 2667f59..e6e50c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,47 +1,27 @@ -use std::{borrow::Borrow, hash::Hash}; - -use fxhash::FxHashMap as HashMap; - mod answer; mod error; mod question; -pub use answer::{Answer, ExpandItem, ListItem}; +pub use answer::{Answer, Answers, ExpandItem, ListItem}; +use crossterm::tty::IsTty; pub use question::{Choice::Choice, Choice::Separator, Question}; -#[derive(Debug, Default)] -pub struct Answers { - answers: HashMap, -} - -impl Answers { - fn insert(&mut self, name: String, answer: Answer) { - self.answers.insert(name, answer); - } - - fn reserve(&mut self, capacity: usize) { - self.answers.reserve(capacity - self.answers.len()) - } - - pub fn contains(&self, question: &Q) -> bool - where - String: Borrow, - Q: Hash + Eq, - { - self.answers.contains_key(question) - } -} - -pub struct PromptModule<'m, 'w, 'f, 'v, 't> { - questions: Vec>, +pub struct PromptModule { + questions: Q, answers: Answers, } -impl<'m, 'w, 'f, 'v, 't> PromptModule<'m, 'w, 'f, 'v, 't> { - pub fn new(questions: Vec>) -> Self { +impl<'m, 'w, 'f, 'v, 't, Q> PromptModule +where + Q: Iterator>, +{ + pub fn new(questions: I) -> Self + where + I: IntoIterator, + { Self { answers: Answers::default(), - questions, + questions: questions.into_iter(), } } @@ -50,24 +30,44 @@ impl<'m, 'w, 'f, 'v, 't> PromptModule<'m, 'w, 'f, 'v, 't> { self } - pub fn prompt_all(self) -> error::Result { - let PromptModule { - questions, - mut answers, - } = self; - - answers.reserve(questions.len()); - - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); - - for question in questions { - if let Some((name, answer)) = question.ask(&answers, &mut stdout)? { - answers.insert(name, answer); + fn prompt_impl( + &mut self, + stdout: &mut W, + ) -> error::Result> { + while let Some(question) = self.questions.next() { + if let Some((name, answer)) = question.ask(&self.answers, stdout)? { + return Ok(Some(self.answers.insert(name, answer))); } } - Ok(answers) + Ok(None) + } + + pub fn prompt(&mut self) -> error::Result> { + let stdout = std::io::stdout(); + if !stdout.is_tty() { + return Err(error::InquirerError::NotATty); + } + let mut stdout = stdout.lock(); + self.prompt_impl(&mut stdout) + } + + pub fn prompt_all(mut self) -> error::Result { + self.answers.reserve(self.questions.size_hint().0); + + let stdout = std::io::stdout(); + if !stdout.is_tty() { + return Err(error::InquirerError::NotATty); + } + let mut stdout = stdout.lock(); + + while self.prompt_impl(&mut stdout)?.is_some() {} + + Ok(self.answers) + } + + pub fn into_answers(self) -> Answers { + self.answers } } diff --git a/src/question/checkbox.rs b/src/question/checkbox.rs index fd41d57..a1fb450 100644 --- a/src/question/checkbox.rs +++ b/src/question/checkbox.rs @@ -16,7 +16,7 @@ pub struct Checkbox<'f, 'v, 't> { selected: Vec, filter: Option>>>, validate: Option>>, - transformer: Option>>, + transformer: Option>>, } impl fmt::Debug for Checkbox<'_, '_, '_> { @@ -40,9 +40,9 @@ struct CheckboxPrompt<'f, 'v, 't, 'a> { answers: &'a Answers, } -impl<'f, 'v, 't> ui::Prompt for CheckboxPrompt<'f, 'v, 't, '_> { +impl ui::Prompt for CheckboxPrompt<'_, '_, '_, '_> { type ValidateErr = String; - type Output = Checkbox<'f, 'v, 't>; + type Output = Vec; fn prompt(&self) -> &str { &self.message @@ -60,7 +60,26 @@ impl<'f, 'v, 't> ui::Prompt for CheckboxPrompt<'f, 'v, 't, '_> { } fn finish(self) -> Self::Output { - self.picker.finish() + let Checkbox { + mut selected, + choices, + filter, + .. + } = self.picker.finish(); + + if let Some(filter) = filter { + selected = filter(selected, self.answers); + } + + selected + .into_iter() + .enumerate() + .zip(choices.choices.into_iter()) + .filter_map(|((index, is_selected), name)| match (is_selected, name) { + (true, Choice::Choice(name)) => Some(ListItem { index, name }), + _ => None, + }) + .collect() } fn has_default(&self) -> bool { @@ -164,16 +183,9 @@ impl Checkbox<'_, '_, '_> { answers: &Answers, w: &mut W, ) -> error::Result { - let filter = self.filter.take(); let transformer = self.transformer.take(); - // We cannot simply process the Vec to a HashSet inside the widget since we - // want to print the selected ones in order - let Checkbox { - mut selected, - choices, - .. - } = ui::Input::new(CheckboxPrompt { + let ans = ui::Input::new(CheckboxPrompt { message, picker: widgets::ListPicker::new(self), answers, @@ -181,40 +193,17 @@ impl Checkbox<'_, '_, '_> { .hide_cursor() .run(w)?; - if let Some(filter) = filter { - selected = filter(selected, answers); - } - match transformer { - Some(transformer) => transformer(&selected, answers, w)?, + Some(transformer) => transformer(&ans, answers, w)?, None => { queue!(w, SetForegroundColor(Color::DarkCyan))?; - print_comma_separated( - selected - .iter() - .zip(choices.choices.iter()) - .filter_map(|item| match item { - (true, Choice::Choice(name)) => Some(name.as_str()), - _ => None, - }), - w, - )?; + print_comma_separated(ans.iter().map(|item| item.name.as_str()), w)?; w.write_all(b"\n")?; execute!(w, ResetColor)?; } } - let ans = selected - .into_iter() - .enumerate() - .zip(choices.choices.into_iter()) - .filter_map(|((index, is_selected), name)| match (is_selected, name) { - (true, Choice::Choice(name)) => Some(ListItem { index, name }), - _ => None, - }) - .collect(); - Ok(Answer::ListItems(ans)) } } @@ -353,7 +342,7 @@ crate::impl_validate_builder!(CheckboxBuilder<'m, 'w, 'f, v, 't> [bool]; (this, } }); -crate::impl_transformer_builder!(CheckboxBuilder<'m, 'w, 'f, 'v, t> [bool]; (this, transformer) => { +crate::impl_transformer_builder!(CheckboxBuilder<'m, 'w, 'f, 'v, t> [ListItem]; (this, transformer) => { CheckboxBuilder { opts: this.opts, checkbox: Checkbox { diff --git a/src/question/expand.rs b/src/question/expand.rs index 6191b69..0d6cfa0 100644 --- a/src/question/expand.rs +++ b/src/question/expand.rs @@ -1,11 +1,11 @@ use std::fmt; +use ahash::AHashSet as HashSet; use crossterm::{ cursor, queue, style::{Color, Colorize, ResetColor, SetForegroundColor}, terminal, }; -use fxhash::FxHashSet as HashSet; use ui::{widgets, Validation, Widget}; use crate::{error, Answer, Answers, ExpandItem}; @@ -417,7 +417,7 @@ impl super::Question<'static, 'static, 'static, 'static, 'static> { ExpandBuilder { opts: Options::new(name.into()), expand: Default::default(), - keys: Default::default(), + keys: HashSet::default(), } } } diff --git a/src/question/mod.rs b/src/question/mod.rs index 49283ea..c393636 100644 --- a/src/question/mod.rs +++ b/src/question/mod.rs @@ -64,7 +64,7 @@ impl Question<'_, '_, '_, '_, '_> { answers: &Answers, w: &mut W, ) -> error::Result> { - if (!self.opts.ask_if_answered && answers.contains(&self.opts.name)) + if (!self.opts.ask_if_answered && answers.contains_key(&self.opts.name)) || !self.opts.when.get(answers) { return Ok(None); diff --git a/ui/src/lib.rs b/ui/src/lib.rs index 4cbc008..988bb10 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -172,10 +172,8 @@ impl Input

{ let prompt_len = u16::try_from(prompt.chars().count() + 3).expect("really big prompt"); let _raw = RawMode::enable()?; - let _cursor = if self.hide_cursor { - Some(HideCursor::enable(stdout)?) - } else { - None + if self.hide_cursor { + queue!(stdout, cursor::Hide)?; }; let height = self.prompt.height(); @@ -228,7 +226,9 @@ impl Input

{ cursor::MoveTo(0, self.base_row + self.prompt.height() as u16) )?; drop(_raw); - drop(_cursor); + if self.hide_cursor { + queue!(stdout, cursor::Show)?; + } exit() } event::KeyCode::Null => { @@ -237,7 +237,9 @@ impl Input

{ cursor::MoveTo(0, self.base_row + self.prompt.height() as u16) )?; drop(_raw); - drop(_cursor); + if self.hide_cursor { + queue!(stdout, cursor::Show)?; + } exit() } event::KeyCode::Esc if self.prompt.has_default() => { @@ -303,7 +305,7 @@ fn exit() -> ! { } /// Simple helper to make sure if the code panics in between, raw mode is disabled -pub struct RawMode { +struct RawMode { _private: (), } @@ -320,25 +322,3 @@ impl Drop for RawMode { let _ = terminal::disable_raw_mode(); } } - -/// Simple helper to make sure if the code panics in between, cursor is shown -pub struct HideCursor { - _private: (), -} - -impl HideCursor { - /// Hide the cursor in the terminal - /// note: it is implicitly bound to stdout because it is required in the destructor - pub fn enable(stdout: &mut W) -> crossterm::Result { - queue!(stdout, cursor::Hide)?; - Ok(Self { _private: () }) - } -} - -impl Drop for HideCursor { - fn drop(&mut self) { - // FIXME: this implicitly binds the hiding cursor to stdout, even though enable is generic - // over any write - let _ = queue!(io::stdout(), cursor::Show); - } -}