From 818e2b54eeb5e51e25efc787a8cbb10684f8c818 Mon Sep 17 00:00:00 2001 From: Lutetium-Vanadium Date: Sat, 10 Apr 2021 17:27:09 +0530 Subject: [PATCH] first question impl There are a lot of things to do, but everything renders well --- Cargo.toml | 4 + src/answer.rs | 24 ++++ src/error.rs | 59 ++++++++ src/lib.rs | 39 +++++- src/main.rs | 184 ++++++++++++++++++++++++ src/question/checkbox.rs | 205 +++++++++++++++++++++++++++ src/question/choice_list.rs | 86 ++++++++++++ src/question/confirm.rs | 101 +++++++++++++ src/question/editor.rs | 97 +++++++++++++ src/question/expand.rs | 272 ++++++++++++++++++++++++++++++++++++ src/question/input.rs | 78 +++++++++++ src/question/list.rs | 149 ++++++++++++++++++++ src/question/mod.rs | 73 ++++++++++ src/question/number.rs | 170 ++++++++++++++++++++++ src/question/password.rs | 98 +++++++++++++ src/question/raw_list.rs | 211 ++++++++++++++++++++++++++++ 16 files changed, 1845 insertions(+), 5 deletions(-) create mode 100644 src/answer.rs create mode 100644 src/error.rs create mode 100644 src/main.rs create mode 100644 src/question/checkbox.rs create mode 100644 src/question/choice_list.rs create mode 100644 src/question/confirm.rs create mode 100644 src/question/editor.rs create mode 100644 src/question/expand.rs create mode 100644 src/question/input.rs create mode 100644 src/question/list.rs create mode 100644 src/question/mod.rs create mode 100644 src/question/number.rs create mode 100644 src/question/password.rs create mode 100644 src/question/raw_list.rs diff --git a/Cargo.toml b/Cargo.toml index ef8332e..9877f4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,7 @@ members = [ ] [dependencies] +crossterm = "0.19.0" +fxhash = "0.2.1" +tempfile = "3" +ui = { path = "./ui" } diff --git a/src/answer.rs b/src/answer.rs new file mode 100644 index 0000000..64f879d --- /dev/null +++ b/src/answer.rs @@ -0,0 +1,24 @@ +use fxhash::FxHashSet as HashSet; + +#[derive(Debug, Clone)] +pub enum Answer { + String(String), + ListItem(ListItem), + ExpandItem(ExpandItem), + Int(i64), + Float(f64), + Bool(bool), + ListItems(HashSet), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ListItem { + pub index: usize, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ExpandItem { + pub key: char, + pub name: String, +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..678d32e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,59 @@ +use std::{fmt, io}; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub enum InquirerError { + IoError(io::Error), + FmtError(fmt::Error), + Utf8Error(std::string::FromUtf8Error), + ParseIntError(std::num::ParseIntError), +} + +impl std::error::Error for InquirerError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + InquirerError::IoError(e) => Some(e), + InquirerError::FmtError(e) => Some(e), + InquirerError::Utf8Error(e) => Some(e), + InquirerError::ParseIntError(e) => Some(e), + } + } +} + +// 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"), + } + } +} + +macro_rules! impl_from { + ($from:path, $e:ident => $body:expr) => { + impl From<$from> for InquirerError { + fn from(e: $from) -> Self { + let $e = e; + $body + } + } + }; +} + +impl_from!(io::Error, e => InquirerError::IoError(e)); +impl_from!(fmt::Error, e => InquirerError::FmtError(e)); +impl_from!(std::string::FromUtf8Error, e => InquirerError::Utf8Error(e)); +impl_from!(std::num::ParseIntError, e => InquirerError::ParseIntError(e)); +impl_from!(crossterm::ErrorKind, e => + match e { + crossterm::ErrorKind::IoError(e) => Self::from(e), + crossterm::ErrorKind::FmtError(e) => Self::from(e), + crossterm::ErrorKind::Utf8Error(e) => Self::from(e), + crossterm::ErrorKind::ParseIntError(e) => Self::from(e), + crossterm::ErrorKind::ResizingTerminalFailure(_) + | crossterm::ErrorKind::SettingTerminalTitleFailure => unreachable!(), + _ => unreachable!(), + } +); diff --git a/src/lib.rs b/src/lib.rs index 31e1bb2..4228cfd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,36 @@ -#[cfg(test)] -mod tests { - #[test] - fn it_works() { - assert_eq!(2 + 2, 4); +// FIXME: remove this +#![allow(dead_code)] + +mod answer; +mod error; +pub mod question; + +pub use answer::{Answer, ExpandItem, ListItem}; +pub use question::{Choice, Choice::Separator, Question}; + +pub struct Inquisition { + questions: Vec, +} + +impl Inquisition { + pub fn new(questions: Vec) -> Self { + Inquisition { questions } + } + + pub fn add_question(&mut self, question: Question) { + self.questions.push(question) + } + + pub fn prompt(self) -> PromptModule { + PromptModule { + answers: Vec::with_capacity(self.questions.len()), + questions: self.questions, + } } } + +// TODO: ask questions +pub struct PromptModule { + questions: Vec, + answers: Vec, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..da81e53 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,184 @@ +// TODO: delete +// this is a temporary file, for testing out the prompts +use inquisition::{Choice::Separator, ExpandItem, Question}; +use std::{env, io}; + +fn main() { + let (a, b) = match env::args().nth(1).as_deref() { + Some("b") => ( + Question::confirm("a".into(), "Hello there 1".into(), true), + Question::confirm("b".into(), "Hello there 2".into(), false), + ), + Some("s") => ( + Question::input("a".into(), "Hello there 1".into(), "No".into()), + Question::input("b".into(), "Hello there 2".into(), "Yes".into()), + ), + Some("p") => ( + Question::password("a".into(), "password 1".into()).with_mask('*'), + Question::password("b".into(), "password 2".into()), + ), + Some("i") => ( + Question::int("a".into(), "int 1".into(), 0), + Question::int("b".into(), "int 2".into(), 3), + ), + Some("f") => ( + Question::float("a".into(), "float 1".into(), 0.123), + Question::float("b".into(), "float 2".into(), 3.12), + ), + Some("e") => ( + Question::editor("a".into(), "editor 1".into()), + Question::editor("b".into(), "editor 2".into()), + ), + + Some("l") => ( + Question::list( + "a".into(), + "list 1".into(), + vec![ + Separator(Some("=== TITLE BOI ===".into())), + "hello worldssssss 1".into(), + "hello worldssssss 2".into(), + "hello worldssssss 3".into(), + "hello worldssssss 4".into(), + "hello worldssssss 5".into(), + ], + 0, + ), + Question::list( + "b".into(), + "list 2".into(), + vec![ + "0".into(), + Separator(None), + "1".into(), + "2".into(), + "3".into(), + Separator(Some("== Hello separator".into())), + ], + 0, + ), + ), + + Some("c") => ( + Question::checkbox( + "a".into(), + "checkbox 1".into(), + vec![ + Separator(Some("=== TITLE BOI ===".into())), + "hello worldssssss 1".into(), + "hello worldssssss 2".into(), + "hello worldssssss 3".into(), + "hello worldssssss 4".into(), + "hello worldssssss 5".into(), + ], + ), + Question::checkbox( + "b".into(), + "checkbox 2".into(), + vec![ + "0".into(), + Separator(None), + "1".into(), + "2".into(), + "3".into(), + Separator(Some("== Hello separator".into())), + ], + ), + ), + + Some("r") => ( + Question::raw_list( + "a".into(), + "list 1".into(), + vec![ + Separator(Some("=== TITLE BOI ===".into())), + "hello worldssssss 1".into(), + "hello worldssssss 2".into(), + "hello worldssssss 3".into(), + "hello worldssssss 4".into(), + "hello worldssssss 5".into(), + ], + 0, + ), + Question::raw_list( + "b".into(), + "list 2".into(), + vec![ + "0".into(), + Separator(None), + "1".into(), + "2".into(), + "3".into(), + Separator(Some("== Hello separator".into())), + ], + 0, + ), + ), + + Some("x") => ( + Question::expand( + "a".into(), + "expand 1".into(), + vec![ + ExpandItem { + key: 'y', + name: "Overwrite".into(), + } + .into(), + ExpandItem { + key: 'a', + name: "Overwrite this one and all next".into(), + } + .into(), + ExpandItem { + key: 'd', + name: "Show diff".into(), + } + .into(), + Separator(None), + ExpandItem { + key: 'x', + name: "Abort".into(), + } + .into(), + ], + None, + ), + Question::expand( + "b".into(), + "expand 2".into(), + vec![ + ExpandItem { + key: 'a', + name: "Name for a".into(), + } + .into(), + Separator(None), + ExpandItem { + key: 'b', + name: "Name for b".into(), + } + .into(), + ExpandItem { + key: 'c', + name: "Name for c".into(), + } + .into(), + Separator(None), + ExpandItem { + key: 'd', + name: "Name for d".into(), + } + .into(), + Separator(Some("== Hello separator".into())), + ], + Some('b'), + ), + ), + _ => panic!("no arg"), + }; + + let mut stdout = io::stdout(); + println!("{:?}", a.ask(&mut stdout)); + println!("{:?}", b.ask(&mut stdout)); +} diff --git a/src/question/checkbox.rs b/src/question/checkbox.rs new file mode 100644 index 0000000..d6c55d2 --- /dev/null +++ b/src/question/checkbox.rs @@ -0,0 +1,205 @@ +use std::io; + +use crossterm::{ + event, execute, queue, + style::{Color, Print, ResetColor, SetForegroundColor}, +}; +use ui::{widgets, Widget}; + +use crate::{answer::ListItem, error, Answer}; + +use super::Options; + +pub struct Checkbox { + // FIXME: What is type here? + choices: super::ChoiceList, + selected: Vec, +} + +struct CheckboxPrompt { + picker: widgets::ListPicker, + opts: Options, +} + +impl ui::Prompt for CheckboxPrompt { + type ValidateErr = &'static str; + type Output = Checkbox; + + fn prompt(&self) -> &str { + &self.opts.message + } + + fn hint(&self) -> Option<&str> { + Some("(Press to select, to toggle all, to invert selection)") + } + + fn finish(self) -> Self::Output { + self.picker.finish() + } + + fn finish_default(self) -> Self::Output { + unreachable!() + } + fn has_default(&self) -> bool { + false + } +} + +impl Widget for CheckboxPrompt { + fn render(&mut self, max_width: usize, w: &mut W) -> crossterm::Result<()> { + self.picker.render(max_width, w) + } + + fn height(&self) -> usize { + self.picker.height() + } + + fn handle_key(&mut self, key: event::KeyEvent) -> bool { + match key.code { + event::KeyCode::Char(' ') => { + let index = self.picker.get_at(); + self.picker.list.selected[index] = !self.picker.list.selected[index]; + } + event::KeyCode::Char('i') => { + self.picker.list.selected.iter_mut().for_each(|s| *s = !*s); + } + event::KeyCode::Char('a') => { + let select_state = self.picker.list.selected.iter().any(|s| !s); + self.picker + .list + .selected + .iter_mut() + .for_each(|s| *s = select_state); + } + _ => return self.picker.handle_key(key), + } + + true + } + + fn cursor_pos(&self, prompt_len: u16) -> (u16, u16) { + self.picker.cursor_pos(prompt_len) + } +} + +impl widgets::List for Checkbox { + fn render_item( + &mut self, + index: usize, + hovered: bool, + max_width: usize, + w: &mut W, + ) -> crossterm::Result<()> { + if hovered { + queue!(w, SetForegroundColor(Color::DarkCyan), Print("❯ "))?; + } else { + w.write_all(b" ")?; + } + + if self.is_selectable(index) { + if self.selected[index] { + queue!(w, SetForegroundColor(Color::Green), Print("◉ "),)?; + + if hovered { + queue!(w, SetForegroundColor(Color::DarkCyan))?; + } else { + queue!(w, ResetColor)?; + } + } else { + w.write_all("◯ ".as_bytes())?; + } + } else { + queue!(w, SetForegroundColor(Color::DarkGrey))?; + } + + self.choices[index].as_str().render(max_width - 4, w)?; + + queue!(w, ResetColor) + } + + fn is_selectable(&self, index: usize) -> bool { + !self.choices[index].is_separator() + } + + fn len(&self) -> usize { + self.choices.len() + } +} + +impl Checkbox { + pub fn ask(self, opts: super::Options, w: &mut W) -> error::Result { + // We cannot simply process the Vec to a HashSet since we want to print the + // selected ones in order + let checkbox = ui::Input::new(CheckboxPrompt { + picker: widgets::ListPicker::new(self), + opts, + }) + .hide_cursor() + .run(w)?; + + queue!(w, SetForegroundColor(Color::DarkCyan))?; + print_comma_separated( + checkbox + .selected + .iter() + .zip(checkbox.choices.choices.iter()) + .filter_map(|item| match item { + (true, super::Choice::Choice(name)) => Some(name.as_str()), + _ => None, + }), + w, + )?; + + w.write_all(b"\n")?; + execute!(w, ResetColor)?; + + let ans = checkbox + .selected + .into_iter() + .enumerate() + .zip(checkbox.choices.choices.into_iter()) + .filter_map(|((index, is_selected), name)| match (is_selected, name) { + (true, super::Choice::Choice(name)) => Some(ListItem { index, name }), + _ => None, + }) + .collect(); + + Ok(Answer::ListItems(ans)) + } +} + +impl super::Question { + pub fn checkbox(name: String, message: String, choices: Vec>) -> Self { + Self::new( + name, + message, + super::QuestionKind::Checkbox(Checkbox { + selected: vec![false; choices.len()], + choices: super::ChoiceList { + choices, + default: 0, + should_loop: true, + // FIXME: this should be something sensible. page size is currently not used so + // its fine for now + page_size: 0, + }, + }), + ) + } +} + +fn print_comma_separated<'a, W: io::Write>( + iter: impl Iterator, + w: &mut W, +) -> io::Result<()> { + let mut iter = iter.peekable(); + + while let Some(item) = iter.next() { + w.write_all(item.as_bytes())?; + if iter.peek().is_some() { + w.write_all(b", ")?; + } + } + + Ok(()) +} diff --git a/src/question/choice_list.rs b/src/question/choice_list.rs new file mode 100644 index 0000000..8baac30 --- /dev/null +++ b/src/question/choice_list.rs @@ -0,0 +1,86 @@ +use std::ops::{Index, IndexMut}; + +pub(crate) struct ChoiceList { + pub(crate) choices: Vec>, + pub(crate) default: usize, + pub(crate) should_loop: bool, + pub(crate) page_size: usize, +} + +impl ChoiceList { + pub(crate) fn len(&self) -> usize { + self.choices.len() + } +} + +impl Index for ChoiceList { + type Output = Choice; + + fn index(&self, index: usize) -> &Self::Output { + &self.choices[index] + } +} + +impl IndexMut for ChoiceList { + fn index_mut(&mut self, index: usize) -> &mut Self::Output { + &mut self.choices[index] + } +} + +pub enum Choice { + Choice(T), + Separator(Option), +} + +impl Choice { + pub(crate) fn is_separator(&self) -> bool { + matches!(self, Choice::Separator(_)) + } + + pub(crate) fn as_ref(&self) -> Choice<&T> { + match self { + Choice::Choice(t) => Choice::Choice(t), + Choice::Separator(s) => Choice::Separator(s.clone()), + } + } + + pub(crate) fn unwrap_choice(self) -> T { + if let Choice::Choice(c) = self { + c + } else { + panic!("Called unwrap_choice on separator") + } + } +} + +pub(crate) fn get_sep_str(separator: &Option) -> &str { + separator + .as_ref() + .map(String::as_str) + .unwrap_or("──────────────") +} + +impl> Choice { + pub(crate) fn as_str(&self) -> &str { + match self { + Choice::Choice(t) => t.as_ref(), + Choice::Separator(s) => get_sep_str(s), + } + } + + pub(crate) fn as_bytes(&self) -> &[u8] { + self.as_str().as_bytes() + } +} + +impl From for Choice { + fn from(t: T) -> Self { + Choice::Choice(t) + } +} + +impl From<&'_ str> for Choice { + fn from(s: &str) -> Self { + Choice::Choice(s.into()) + } +} diff --git a/src/question/confirm.rs b/src/question/confirm.rs new file mode 100644 index 0000000..658994d --- /dev/null +++ b/src/question/confirm.rs @@ -0,0 +1,101 @@ +use crossterm::style::Colorize; +use ui::{widgets, Widget}; + +use crate::{error, Answer}; + +use super::Options; + +pub struct Confirm { + pub(crate) default: bool, +} + +struct ConfirmPrompt { + confirm: Confirm, + opts: Options, + input: widgets::CharInput, +} + +impl Widget for ConfirmPrompt { + fn render(&mut self, max_width: usize, w: &mut W) -> crossterm::Result<()> { + self.input.render(max_width, w) + } + + fn height(&self) -> usize { + self.input.height() + } + + fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + self.input.handle_key(key) + } + + fn cursor_pos(&self, prompt_len: u16) -> (u16, u16) { + self.input.cursor_pos(prompt_len) + } +} + +fn only_yn(c: char) -> Option { + match c { + 'y' | 'Y' | 'n' | 'N' => Some(c), + _ => None, + } +} + +impl ui::Prompt for ConfirmPrompt { + type ValidateErr = &'static str; + type Output = bool; + + fn prompt(&self) -> &str { + &self.opts.message + } + + fn hint(&self) -> Option<&str> { + if self.confirm.default { + Some("(y/n) (default y)") + } else { + Some("(y/n) (default n)") + } + } + + fn finish(self) -> Self::Output { + match self.input.finish() { + Some('y') | Some('Y') => true, + Some('n') | Some('N') => true, + _ => unreachable!(), + } + } + + fn finish_default(self) -> Self::Output { + self.confirm.default + } +} + +impl Confirm { + pub(crate) fn ask( + self, + opts: super::Options, + w: &mut W, + ) -> error::Result { + let ans = ui::Input::new(ConfirmPrompt { + confirm: self, + opts, + input: widgets::CharInput::new(only_yn), + }) + .run(w)?; + + let s = if ans { "Yes" } else { "No" }; + + writeln!(w, "{}", s.dark_cyan())?; + + Ok(Answer::Bool(ans)) + } +} + +impl super::Question { + pub fn confirm(name: String, message: String, default: bool) -> Self { + Self::new( + name, + message, + super::QuestionKind::Confirm(Confirm { default }), + ) + } +} diff --git a/src/question/editor.rs b/src/question/editor.rs new file mode 100644 index 0000000..c6acb1d --- /dev/null +++ b/src/question/editor.rs @@ -0,0 +1,97 @@ +use std::{ + env, + ffi::OsString, + io::{self, Read}, + process::Command, +}; + +use crossterm::style::Colorize; +use ui::Widget; + +use crate::{error, Answer}; + +use super::{Options, Question, QuestionKind}; + +pub struct Editor { + // FIXME: What is correct type here? + default: String, + // TODO: What is this?? + postfix: (), +} + +fn get_editor() -> OsString { + env::var_os("VISUAL") + .or_else(|| env::var_os("EDITOR")) + .unwrap_or_else(|| { + if cfg!(windows) { + "notepad".into() + } else { + "vim".into() + } + }) +} + +struct EditorPrompt { + opts: Options, +} + +impl Widget for EditorPrompt { + fn render(&mut self, _: usize, _: &mut W) -> crossterm::Result<()> { + Ok(()) + } + + fn height(&self) -> usize { + 0 + } +} + +impl ui::Prompt for EditorPrompt { + type ValidateErr = &'static str; + type Output = (); + + fn prompt(&self) -> &str { + &self.opts.message + } + + fn hint(&self) -> Option<&str> { + Some("Press to launch your preferred editor.") + } + + fn finish(self) -> Self::Output {} + + fn finish_default(self) -> Self::Output {} +} + +impl Editor { + pub fn ask(self, opts: Options, w: &mut W) -> error::Result { + ui::Input::new(EditorPrompt { opts }).run(w)?; + + let mut file = tempfile::NamedTempFile::new()?; + + // FIXME: handle error + assert!(Command::new(get_editor()) + .arg(file.path()) + .status()? + .success()); + + let mut ans = String::new(); + file.read_to_string(&mut ans)?; + + writeln!(w, "{}", "Received".dark_grey())?; + + Ok(Answer::String(ans)) + } +} + +impl Question { + pub fn editor(name: String, message: String) -> Self { + Self::new( + name, + message, + QuestionKind::Editor(Editor { + default: String::new(), + postfix: (), + }), + ) + } +} diff --git a/src/question/expand.rs b/src/question/expand.rs new file mode 100644 index 0000000..1e16d93 --- /dev/null +++ b/src/question/expand.rs @@ -0,0 +1,272 @@ +use crossterm::{ + cursor, queue, + style::{Color, Colorize, ResetColor, SetForegroundColor}, + terminal, +}; +use ui::{widgets, Validation, Widget}; + +use crate::{answer::ExpandItem, error, Answer}; + +use super::{Choice, Options}; + +pub struct Expand { + choices: super::ChoiceList, + selected: Option, + default: Option, +} + +struct ExpandPrompt<'a, F> { + list: widgets::ListPicker, + input: widgets::CharInput, + opts: Options, + hint: &'a str, + expanded: bool, +} + +impl ExpandPrompt<'_, F> { + fn finish_with(self, c: char) -> ExpandItem { + self.list + .finish() + .choices + .choices + .into_iter() + .filter_map(|choice| match choice { + Choice::Choice(choice) => Some(choice), + _ => None, + }) + .find(|item| item.key == c) + .unwrap() + } +} + +impl Option> ui::Prompt for ExpandPrompt<'_, F> { + type ValidateErr = &'static str; + type Output = ExpandItem; + + fn prompt(&self) -> &str { + &self.opts.message + } + + fn hint(&self) -> Option<&str> { + Some(self.hint) + } + + fn validate(&mut self) -> Result { + match self.input.value() { + Some('h') => { + self.expanded = true; + self.input.set_value(None); + self.list.list.selected = None; + Ok(Validation::Continue) + } + None if self.list.list.default.is_none() => Err("Please enter a command"), + _ => Ok(Validation::Finish), + } + } + + fn finish(self) -> Self::Output { + let c = self.input.value().unwrap(); + self.finish_with(c) + } + + fn has_default(&self) -> bool { + self.list.list.default.is_some() + } + + fn finish_default(self) -> Self::Output { + let c = self.list.list.default.unwrap(); + self.finish_with(c) + } +} + +const ANSWER_PROMPT: &[u8] = b" Answer: "; + +impl Option> ui::Widget for ExpandPrompt<'_, F> { + fn render(&mut self, max_width: usize, w: &mut W) -> crossterm::Result<()> { + if self.expanded { + let max_width = terminal::size()?.0 as usize - ANSWER_PROMPT.len(); + self.list.render(max_width, w)?; + w.write_all(ANSWER_PROMPT)?; + self.input.render(max_width, w) + } else { + self.input.render(max_width, w)?; + + if let Some(key) = self.input.value() { + let name = &self + .list + .list + .choices + .choices + .iter() + .filter_map(|choice| match choice { + Choice::Choice(choice) => Some(choice), + _ => None, + }) + .find(|item| item.key == key) + .map(|item| &*item.name) + .unwrap_or("Help, list all options"); + + queue!(w, cursor::MoveToNextLine(1))?; + + write!(w, "{} {}", ">>".dark_cyan(), name)?; + } + + Ok(()) + } + } + + fn height(&self) -> usize { + if self.expanded { + self.list.height() + 1 + } else if self.input.value().is_some() { + self.input.height() + 1 + } else { + self.input.height() + } + } + + fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + if self.input.handle_key(key) { + self.list.list.selected = self.input.value(); + true + } else if self.expanded { + self.list.handle_key(key) + } else { + false + } + } + + fn cursor_pos(&self, prompt_len: u16) -> (u16, u16) { + if self.expanded { + let w = self.input.cursor_pos(ANSWER_PROMPT.len() as u16).0; + (w, self.height() as u16) + } else { + self.input.cursor_pos(prompt_len) + } + } +} + +impl Expand { + fn render_choice( + &self, + item: &ExpandItem, + max_width: usize, + w: &mut W, + ) -> crossterm::Result<()> { + let hovered = self.selected.map(|c| c == item.key).unwrap_or(false); + + if hovered { + queue!(w, SetForegroundColor(Color::DarkCyan))?; + } + + write!(w, " {}) ", item.key)?; + item.name.as_str().render(max_width - 5, w)?; + + if hovered { + queue!(w, ResetColor)?; + } + + Ok(()) + } +} + +thread_local! { + static HELP_CHOICE: ExpandItem = ExpandItem { + key: 'h', + name: "Help, list all options".into(), + }; +} + +impl widgets::List for Expand { + fn render_item( + &mut self, + index: usize, + _: bool, + max_width: usize, + w: &mut W, + ) -> crossterm::Result<()> { + if index == self.choices.len() { + return HELP_CHOICE.with(|h| self.render_choice(h, max_width, w)); + } + + match &self.choices[index] { + Choice::Choice(item) => self.render_choice(item, max_width, w), + Choice::Separator(s) => { + queue!(w, SetForegroundColor(Color::DarkGrey))?; + w.write_all(b" ")?; + super::get_sep_str(s).render(max_width - 3, w)?; + queue!(w, ResetColor) + } + } + } + + fn is_selectable(&self, _: usize) -> bool { + true + } + + fn len(&self) -> usize { + self.choices.len() + 1 + } +} + +impl Expand { + pub fn ask(self, opts: Options, w: &mut W) -> error::Result { + let hint: String = { + let mut s = String::with_capacity(3 + self.choices.len()); + s.push('('); + s.extend( + self.choices + .choices + .iter() + .filter_map(|choice| match choice { + Choice::Choice(choice) => Some(choice.key), + _ => None, + }), + ); + s += "h)"; + s + }; + + let ans = ui::Input::new(ExpandPrompt { + input: widgets::CharInput::new(|c| { + let c = c.to_ascii_lowercase(); + hint[1..(hint.len() - 1)].contains(c).then(|| c) + }), + list: widgets::ListPicker::new(self), + opts, + hint: &hint, + expanded: false, + }) + .run(w)?; + + writeln!(w, "{}", ans.name.as_str().dark_cyan())?; + + Ok(Answer::ExpandItem(ans)) + } +} + +impl super::Question { + pub fn expand( + name: String, + message: String, + choices: Vec>, + default: Option, + ) -> Self { + Self::new( + name, + message, + super::QuestionKind::Expand(Expand { + choices: super::ChoiceList { + choices, + default: 0, + should_loop: true, + // FIXME: this should be something sensible. page size is currently not used so + // its fine for now + page_size: 0, + }, + selected: None, + default, + }), + ) + } +} diff --git a/src/question/input.rs b/src/question/input.rs new file mode 100644 index 0000000..6c99af5 --- /dev/null +++ b/src/question/input.rs @@ -0,0 +1,78 @@ +use crossterm::style::Colorize; +use ui::{widgets, Widget}; + +use crate::{error, Answer}; + +use super::Options; + +pub struct Input { + // FIXME: reference instead? + pub(crate) default: String, +} + +struct InputPrompt { + input_opts: Input, + opts: Options, + input: widgets::StringInput, + hint: String, +} + +impl Widget for InputPrompt { + fn render(&mut self, max_width: usize, w: &mut W) -> crossterm::Result<()> { + self.input.render(max_width, w) + } + + fn height(&self) -> usize { + self.input.height() + } + + fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + self.input.handle_key(key) + } + + fn cursor_pos(&self, prompt_len: u16) -> (u16, u16) { + self.input.cursor_pos(prompt_len) + } +} + +impl ui::Prompt for InputPrompt { + type ValidateErr = &'static str; + type Output = String; + + fn prompt(&self) -> &str { + &self.opts.message + } + + fn hint(&self) -> Option<&str> { + Some(&self.hint) + } + + fn finish(self) -> Self::Output { + self.input.finish().unwrap_or(self.input_opts.default) + } + fn finish_default(self) -> ::Output { + self.input_opts.default + } +} + +impl Input { + pub fn ask(self, opts: Options, w: &mut W) -> error::Result { + let ans = ui::Input::new(InputPrompt { + opts, + hint: format!("({})", self.default), + input_opts: self, + input: widgets::StringInput::default(), + }) + .run(w)?; + + writeln!(w, "{}", ans.as_str().dark_cyan())?; + + Ok(Answer::String(ans)) + } +} + +impl super::Question { + pub fn input(name: String, message: String, default: String) -> Self { + Self::new(name, message, super::QuestionKind::Input(Input { default })) + } +} diff --git a/src/question/list.rs b/src/question/list.rs new file mode 100644 index 0000000..3017dc1 --- /dev/null +++ b/src/question/list.rs @@ -0,0 +1,149 @@ +use crossterm::{ + queue, + style::{Color, Colorize, Print, ResetColor, SetForegroundColor}, +}; +use ui::{widgets, Widget}; + +use crate::{ + answer::{Answer, ListItem}, + error, +}; + +use super::Options; + +pub struct List { + // FIXME: Whats the correct type? + choices: super::ChoiceList, +} + +struct ListPrompt { + picker: widgets::ListPicker, + opts: Options, +} + +impl ListPrompt { + fn finish_index(self, index: usize) -> ListItem { + ListItem { + index, + name: self + .picker + .finish() + .choices + .choices + .swap_remove(index) + .unwrap_choice(), + } + } +} + +impl ui::Prompt for ListPrompt { + type ValidateErr = &'static str; + type Output = ListItem; + + fn prompt(&self) -> &str { + &self.opts.message + } + + fn hint(&self) -> Option<&str> { + Some("(Use arrow keys)") + } + + fn finish(self) -> Self::Output { + let index = self.picker.get_at(); + self.finish_index(index) + } + + fn finish_default(self) -> Self::Output { + let index = self.picker.list.choices.default; + self.finish_index(index) + } +} + +impl Widget for ListPrompt { + fn render(&mut self, max_width: usize, w: &mut W) -> crossterm::Result<()> { + self.picker.render(max_width, w) + } + + fn height(&self) -> usize { + self.picker.height() + } + + fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + self.picker.handle_key(key) + } + + fn cursor_pos(&self, prompt_len: u16) -> (u16, u16) { + self.picker.cursor_pos(prompt_len) + } +} + +impl widgets::List for List { + fn render_item( + &mut self, + index: usize, + hovered: bool, + max_width: usize, + w: &mut W, + ) -> crossterm::Result<()> { + if hovered { + queue!(w, SetForegroundColor(Color::DarkCyan), Print("❯ "))?; + } else { + w.write_all(b" ")?; + + if !self.is_selectable(index) { + queue!(w, SetForegroundColor(Color::DarkGrey))?; + } + } + + self.choices[index].as_str().render(max_width - 2, w)?; + + queue!(w, ResetColor) + } + + fn is_selectable(&self, index: usize) -> bool { + !self.choices[index].is_separator() + } + + fn len(&self) -> usize { + self.choices.len() + } +} + +impl List { + pub fn ask(self, opts: Options, w: &mut W) -> error::Result { + let ans = ui::Input::new(ListPrompt { + picker: widgets::ListPicker::new(self), + opts, + }) + .hide_cursor() + .run(w)?; + + writeln!(w, "{}", ans.name.as_str().dark_cyan())?; + + Ok(Answer::ListItem(ans)) + } +} + +impl super::Question { + pub fn list( + name: String, + message: String, + choices: Vec>, + default: usize, + ) -> Self { + Self::new( + name, + message, + super::QuestionKind::List(List { + choices: super::ChoiceList { + choices, + default, + should_loop: true, + // FIXME: this should be something sensible. page size is currently not used so + // its fine for now + page_size: 0, + }, + }), + ) + } +} diff --git a/src/question/mod.rs b/src/question/mod.rs new file mode 100644 index 0000000..a41ac0e --- /dev/null +++ b/src/question/mod.rs @@ -0,0 +1,73 @@ +mod checkbox; +mod choice_list; +mod confirm; +mod editor; +mod expand; +mod input; +mod list; +mod number; +mod password; +mod raw_list; + +use crate::{error, Answer}; +pub use choice_list::Choice; +use choice_list::{get_sep_str, ChoiceList}; + +use std::io::prelude::*; + +pub struct Options { + // FIXME: reference instead? + name: String, + // FIXME: reference instead? Dynamic messages? + message: String, + // FIXME: Wrong type + when: bool, +} + +pub struct Question { + kind: QuestionKind, + opts: Options, +} + +impl Question { + fn new(name: String, message: String, kind: QuestionKind) -> Self { + Self { + opts: Options { + name, + message, + when: true, + }, + kind, + } + } +} + +enum QuestionKind { + Input(input::Input), + Int(number::Int), + Float(number::Float), + Confirm(confirm::Confirm), + List(list::List), + Rawlist(raw_list::Rawlist), + Expand(expand::Expand), + Checkbox(checkbox::Checkbox), + Password(password::Password), + Editor(editor::Editor), +} + +impl Question { + pub fn ask(self, w: &mut W) -> error::Result { + match self.kind { + QuestionKind::Input(i) => i.ask(self.opts, w), + QuestionKind::Int(i) => i.ask(self.opts, w), + QuestionKind::Float(f) => f.ask(self.opts, w), + QuestionKind::Confirm(c) => c.ask(self.opts, w), + QuestionKind::List(l) => l.ask(self.opts, w), + QuestionKind::Rawlist(r) => r.ask(self.opts, w), + QuestionKind::Expand(e) => e.ask(self.opts, w), + QuestionKind::Checkbox(c) => c.ask(self.opts, w), + QuestionKind::Password(p) => p.ask(self.opts, w), + QuestionKind::Editor(e) => e.ask(self.opts, w), + } + } +} diff --git a/src/question/number.rs b/src/question/number.rs new file mode 100644 index 0000000..e41067c --- /dev/null +++ b/src/question/number.rs @@ -0,0 +1,170 @@ +use crossterm::style::{Color, ResetColor, SetForegroundColor}; +use ui::{widgets, Validation, Widget}; + +use crate::{error, Answer}; + +use super::Options; + +pub struct Float { + default: f64, +} + +pub struct Int { + default: i64, +} + +trait Number { + type Inner; + + fn filter_map_char(c: char) -> Option; + fn parse(s: &str) -> Result; + fn default(&self) -> Self::Inner; + fn finish(inner: Self::Inner, w: &mut W) -> error::Result; +} + +impl Number for Int { + type Inner = i64; + + fn filter_map_char(c: char) -> Option { + if c.is_digit(10) || c == '-' || c == '+' { + Some(c) + } else { + None + } + } + + fn parse(s: &str) -> Result { + s.parse::().map_err(|e| e.to_string()) + } + + fn default(&self) -> Self::Inner { + self.default + } + + fn finish(i: Self::Inner, w: &mut W) -> error::Result { + writeln!( + w, + "{}{}{}", + SetForegroundColor(Color::DarkCyan), + i, + ResetColor, + )?; + + Ok(Answer::Int(i)) + } +} +impl Number for Float { + type Inner = f64; + + fn filter_map_char(c: char) -> Option { + if c.is_digit(10) || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-' { + Some(c) + } else { + None + } + } + + fn parse(s: &str) -> Result { + s.parse::().map_err(|e| e.to_string()) + } + + fn default(&self) -> Self::Inner { + self.default + } + + fn finish(f: Self::Inner, w: &mut W) -> error::Result { + write!(w, "{}", SetForegroundColor(Color::DarkCyan))?; + if f > 1e20 { + write!(w, "{:e}", f)?; + } else { + write!(w, "{}", f)?; + } + writeln!(w, "{}", ResetColor)?; + + Ok(Answer::Float(f)) + } +} + +struct NumberPrompt { + number: N, + opts: Options, + input: widgets::StringInput, + hint: String, +} + +impl Widget for NumberPrompt { + fn render(&mut self, max_width: usize, w: &mut W) -> crossterm::Result<()> { + self.input.render(max_width, w) + } + + fn height(&self) -> usize { + self.input.height() + } + + fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + self.input.handle_key(key) + } + + fn cursor_pos(&self, prompt_len: u16) -> (u16, u16) { + self.input.cursor_pos(prompt_len) + } +} + +impl ui::Prompt for NumberPrompt { + type ValidateErr = String; + type Output = N::Inner; + + fn prompt(&self) -> &str { + &self.opts.message + } + + fn hint(&self) -> Option<&str> { + Some(&self.hint) + } + + fn validate(&mut self) -> Result { + N::parse(self.input.value()).map(|_| Validation::Finish) + } + + fn finish(self) -> Self::Output { + N::parse(self.input.value()).unwrap_or_else(|_| self.number.default()) + } + fn finish_default(self) -> Self::Output { + self.number.default() + } +} + +macro_rules! impl_ask { + ($t:ty) => { + impl $t { + pub(crate) fn ask( + self, + opts: super::Options, + w: &mut W, + ) -> error::Result { + let ans = ui::Input::new(NumberPrompt { + hint: format!("({})", self.default), + input: widgets::StringInput::new(Self::filter_map_char), + number: self, + opts, + }) + .run(w)?; + + Self::finish(ans, w) + } + } + }; +} + +impl_ask!(Int); +impl_ask!(Float); + +impl super::Question { + pub fn int(name: String, message: String, default: i64) -> Self { + Self::new(name, message, super::QuestionKind::Int(Int { default })) + } + + pub fn float(name: String, message: String, default: f64) -> Self { + Self::new(name, message, super::QuestionKind::Float(Float { default })) + } +} diff --git a/src/question/password.rs b/src/question/password.rs new file mode 100644 index 0000000..43a58e9 --- /dev/null +++ b/src/question/password.rs @@ -0,0 +1,98 @@ +use crossterm::style::Colorize; +use ui::{widgets, Widget}; + +use crate::{error, Answer}; + +use super::Options; + +pub struct Password { + pub(crate) mask: Option, +} + +struct PasswordPrompt { + password: Password, + input: widgets::StringInput, + opts: Options, +} + +impl ui::Prompt for PasswordPrompt { + type ValidateErr = &'static str; + type Output = String; + + fn prompt(&self) -> &str { + &self.opts.message + } + + fn hint(&self) -> Option<&str> { + if self.password.mask.is_none() { + Some("[input is hidden]") + } else { + None + } + } + + fn finish(self) -> Self::Output { + self.input.finish().unwrap_or_else(String::new) + } + + fn has_default(&self) -> bool { + false + } + + fn finish_default(self) -> Self::Output { + unreachable!() + } +} + +impl Widget for PasswordPrompt { + fn render(&mut self, max_width: usize, w: &mut W) -> crossterm::Result<()> { + self.input.render(max_width, w) + } + + fn height(&self) -> usize { + self.input.height() + } + + fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + self.input.handle_key(key) + } + + fn cursor_pos(&self, prompt_len: u16) -> (u16, u16) { + self.input.cursor_pos(prompt_len) + } +} + +impl Password { + pub fn ask(self, opts: Options, w: &mut W) -> error::Result { + let ans = ui::Input::new(PasswordPrompt { + input: widgets::StringInput::new(widgets::no_filter as _).password(self.mask), + password: self, + opts, + }) + .run(w)?; + + writeln!(w, "{}", "[hidden]".dark_grey())?; + + Ok(Answer::String(ans)) + } +} + +impl super::Question { + pub fn password(name: String, message: String) -> Self { + Self::new( + name, + message, + super::QuestionKind::Password(Password { mask: None }), + ) + } + + pub fn with_mask(mut self, mask: char) -> Self { + if let super::QuestionKind::Password(ref mut p) = self.kind { + p.mask = Some(mask); + } else { + unreachable!("with_mask should only be called when a question is password") + } + + self + } +} diff --git a/src/question/raw_list.rs b/src/question/raw_list.rs new file mode 100644 index 0000000..acfc6b3 --- /dev/null +++ b/src/question/raw_list.rs @@ -0,0 +1,211 @@ +use crossterm::{ + event, queue, + style::{Color, Colorize, ResetColor, SetForegroundColor}, + terminal, +}; +use ui::{widgets, Validation, Widget}; +use widgets::List; + +use crate::{ + answer::{Answer, ListItem}, + error, +}; + +use super::{Choice, Options}; + +pub struct Rawlist { + // FIXME: Whats the correct type? + choices: super::ChoiceList<(usize, String)>, +} + +struct RawlistPrompt { + list: widgets::ListPicker, + input: widgets::StringInput, + opts: Options, +} + +impl RawlistPrompt { + fn finish_index(self, index: usize) -> ListItem { + ListItem { + index, + name: self + .list + .finish() + .choices + .choices + .swap_remove(index) + .unwrap_choice() + .1, + } + } +} + +impl ui::Prompt for RawlistPrompt { + type ValidateErr = &'static str; + type Output = ListItem; + + fn prompt(&self) -> &str { + &self.opts.message + } + + fn hint(&self) -> Option<&str> { + None + } + + fn validate(&mut self) -> Result { + if self.list.get_at() >= self.list.list.len() { + Err("Please enter a valid index") + } else { + Ok(Validation::Finish) + } + } + + fn finish(self) -> Self::Output { + let index = self.list.get_at(); + self.finish_index(index) + } + + fn finish_default(self) -> Self::Output { + let index = self.list.list.choices.default; + self.finish_index(index) + } +} + +const ANSWER_PROMPT: &[u8] = b" Answer: "; + +impl Widget for RawlistPrompt { + fn render(&mut self, _: usize, w: &mut W) -> crossterm::Result<()> { + let max_width = terminal::size()?.0 as usize; + self.list.render(max_width, w)?; + w.write_all(ANSWER_PROMPT)?; + self.input.render(max_width - ANSWER_PROMPT.len(), w) + } + + fn height(&self) -> usize { + self.list.height() + 1 + } + + fn handle_key(&mut self, key: event::KeyEvent) -> bool { + if self.input.handle_key(key) { + if let Ok(mut n) = self.input.value().parse::() { + if n < self.list.list.len() && n > 0 { + // Choices are 1 indexed for the user + n -= 1; + + let pos = self.list.list.choices.choices[n..] + .iter() + .position(|choice| matches!(choice, Choice::Choice((i, _)) if *i == n)); + + if let Some(pos) = pos { + self.list.set_at(pos + n); + return true; + } + } + } + + self.list.set_at(self.list.list.len() + 1); + true + } else if self.list.handle_key(key) { + self.input.set_value(self.list.get_at().to_string()); + true + } else { + false + } + } + + fn cursor_pos(&self, _: u16) -> (u16, u16) { + let w = self.input.cursor_pos(ANSWER_PROMPT.len() as u16).0; + (w, self.height() as u16) + } +} + +impl widgets::List for Rawlist { + fn render_item( + &mut self, + index: usize, + hovered: bool, + max_width: usize, + w: &mut W, + ) -> crossterm::Result<()> { + match &self.choices[index] { + Choice::Choice((index, name)) => { + if hovered { + queue!(w, SetForegroundColor(Color::DarkCyan))?; + } + + write!(w, " {}) ", index + 1)?; + name.as_str() + .render(max_width - (*index as f64).log10() as usize + 5, w)?; + + if hovered { + queue!(w, ResetColor)?; + } + } + Choice::Separator(s) => { + queue!(w, SetForegroundColor(Color::DarkGrey))?; + w.write_all(b" ")?; + super::get_sep_str(s).render(max_width - 3, w)?; + queue!(w, ResetColor)?; + } + } + + Ok(()) + } + + fn is_selectable(&self, index: usize) -> bool { + !self.choices[index].is_separator() + } + + fn len(&self) -> usize { + self.choices.len() + } +} + +impl Rawlist { + pub fn ask(self, opts: Options, w: &mut W) -> error::Result { + let ans = ui::Input::new(RawlistPrompt { + input: widgets::StringInput::new(|c| c.is_digit(10).then(|| c)), + list: widgets::ListPicker::new(self), + opts, + }) + .run(w)?; + + writeln!(w, "{}", ans.name.as_str().dark_cyan())?; + + Ok(Answer::ListItem(ans)) + } +} + +impl super::Question { + pub fn raw_list( + name: String, + message: String, + choices: Vec>, + default: usize, + ) -> Self { + Self::new( + name, + message, + super::QuestionKind::Rawlist(Rawlist { + choices: super::ChoiceList { + choices: choices + .into_iter() + .scan(0, |index, choice| match choice { + Choice::Choice(s) => { + let res = Choice::Choice((*index, s)); + *index += 1; + Some(res) + } + Choice::Separator(s) => Some(Choice::Separator(s)), + }) + .collect(), + default, + should_loop: true, + // FIXME: this should be something sensible. page size is currently not used so + // its fine for now + page_size: 0, + }, + }), + ) + } +}