first question impl

There are a lot of things to do, but everything renders well
This commit is contained in:
Lutetium-Vanadium 2021-04-10 17:27:09 +05:30
parent c1aa566d97
commit 818e2b54ee
16 changed files with 1845 additions and 5 deletions

View File

@ -11,3 +11,7 @@ members = [
]
[dependencies]
crossterm = "0.19.0"
fxhash = "0.2.1"
tempfile = "3"
ui = { path = "./ui" }

24
src/answer.rs Normal file
View File

@ -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<ListItem>),
}
#[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,
}

59
src/error.rs Normal file
View File

@ -0,0 +1,59 @@
use std::{fmt, io};
pub type Result<T> = std::result::Result<T, InquirerError>;
#[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!(),
}
);

View File

@ -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<Question>,
}
impl Inquisition {
pub fn new(questions: Vec<Question>) -> 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<Question>,
answers: Vec<Answer>,
}

184
src/main.rs Normal file
View File

@ -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));
}

205
src/question/checkbox.rs Normal file
View File

@ -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<String>,
selected: Vec<bool>,
}
struct CheckboxPrompt {
picker: widgets::ListPicker<Checkbox>,
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 <space> to select, <a> to toggle all, <i> 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<W: io::Write>(&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<W: io::Write>(
&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<W: io::Write>(self, opts: super::Options, w: &mut W) -> error::Result<Answer> {
// We cannot simply process the Vec<bool> to a HashSet<ListItem> 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<super::Choice<String>>) -> 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<Item = &'a str>,
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(())
}

View File

@ -0,0 +1,86 @@
use std::ops::{Index, IndexMut};
pub(crate) struct ChoiceList<T> {
pub(crate) choices: Vec<Choice<T>>,
pub(crate) default: usize,
pub(crate) should_loop: bool,
pub(crate) page_size: usize,
}
impl<T> ChoiceList<T> {
pub(crate) fn len(&self) -> usize {
self.choices.len()
}
}
impl<T> Index<usize> for ChoiceList<T> {
type Output = Choice<T>;
fn index(&self, index: usize) -> &Self::Output {
&self.choices[index]
}
}
impl<T> IndexMut<usize> for ChoiceList<T> {
fn index_mut(&mut self, index: usize) -> &mut Self::Output {
&mut self.choices[index]
}
}
pub enum Choice<T> {
Choice(T),
Separator(Option<String>),
}
impl<T> Choice<T> {
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<String>) -> &str {
separator
.as_ref()
.map(String::as_str)
.unwrap_or("──────────────")
}
impl<T: AsRef<str>> Choice<T> {
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<T> From<T> for Choice<T> {
fn from(t: T) -> Self {
Choice::Choice(t)
}
}
impl From<&'_ str> for Choice<String> {
fn from(s: &str) -> Self {
Choice::Choice(s.into())
}
}

101
src/question/confirm.rs Normal file
View File

@ -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<W: std::io::Write>(&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<char> {
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<W: std::io::Write>(
self,
opts: super::Options,
w: &mut W,
) -> error::Result<Answer> {
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 }),
)
}
}

97
src/question/editor.rs Normal file
View File

@ -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<W: io::Write>(&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 <enter> to launch your preferred editor.")
}
fn finish(self) -> Self::Output {}
fn finish_default(self) -> Self::Output {}
}
impl Editor {
pub fn ask<W: io::Write>(self, opts: Options, w: &mut W) -> error::Result<Answer> {
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: (),
}),
)
}
}

272
src/question/expand.rs Normal file
View File

@ -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<ExpandItem>,
selected: Option<char>,
default: Option<char>,
}
struct ExpandPrompt<'a, F> {
list: widgets::ListPicker<Expand>,
input: widgets::CharInput<F>,
opts: Options,
hint: &'a str,
expanded: bool,
}
impl<F> 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<F: Fn(char) -> Option<char>> 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<Validation, Self::ValidateErr> {
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<F: Fn(char) -> Option<char>> ui::Widget for ExpandPrompt<'_, F> {
fn render<W: std::io::Write>(&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<W: std::io::Write>(
&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<W: std::io::Write>(
&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<W: std::io::Write>(self, opts: Options, w: &mut W) -> error::Result<Answer> {
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<Choice<ExpandItem>>,
default: Option<char>,
) -> 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,
}),
)
}
}

78
src/question/input.rs Normal file
View File

@ -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<W: std::io::Write>(&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) -> <Self as ui::Prompt>::Output {
self.input_opts.default
}
}
impl Input {
pub fn ask<W: std::io::Write>(self, opts: Options, w: &mut W) -> error::Result<Answer> {
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 }))
}
}

149
src/question/list.rs Normal file
View File

@ -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<String>,
}
struct ListPrompt {
picker: widgets::ListPicker<List>,
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<W: std::io::Write>(&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<W: std::io::Write>(
&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<W: std::io::Write>(self, opts: Options, w: &mut W) -> error::Result<Answer> {
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<super::Choice<String>>,
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,
},
}),
)
}
}

73
src/question/mod.rs Normal file
View File

@ -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<W: Write>(self, w: &mut W) -> error::Result<Answer> {
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),
}
}
}

170
src/question/number.rs Normal file
View File

@ -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<char>;
fn parse(s: &str) -> Result<Self::Inner, String>;
fn default(&self) -> Self::Inner;
fn finish<W: std::io::Write>(inner: Self::Inner, w: &mut W) -> error::Result<Answer>;
}
impl Number for Int {
type Inner = i64;
fn filter_map_char(c: char) -> Option<char> {
if c.is_digit(10) || c == '-' || c == '+' {
Some(c)
} else {
None
}
}
fn parse(s: &str) -> Result<i64, String> {
s.parse::<i64>().map_err(|e| e.to_string())
}
fn default(&self) -> Self::Inner {
self.default
}
fn finish<W: std::io::Write>(i: Self::Inner, w: &mut W) -> error::Result<Answer> {
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<char> {
if c.is_digit(10) || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-' {
Some(c)
} else {
None
}
}
fn parse(s: &str) -> Result<f64, String> {
s.parse::<f64>().map_err(|e| e.to_string())
}
fn default(&self) -> Self::Inner {
self.default
}
fn finish<W: std::io::Write>(f: Self::Inner, w: &mut W) -> error::Result<Answer> {
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<N> {
number: N,
opts: Options,
input: widgets::StringInput,
hint: String,
}
impl<N: Number> Widget for NumberPrompt<N> {
fn render<W: std::io::Write>(&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<N: Number> ui::Prompt for NumberPrompt<N> {
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<Validation, Self::ValidateErr> {
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<W: std::io::Write>(
self,
opts: super::Options,
w: &mut W,
) -> error::Result<Answer> {
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 }))
}
}

98
src/question/password.rs Normal file
View File

@ -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<char>,
}
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<W: std::io::Write>(&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<W: std::io::Write>(self, opts: Options, w: &mut W) -> error::Result<Answer> {
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
}
}

211
src/question/raw_list.rs Normal file
View File

@ -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<Rawlist>,
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<Validation, Self::ValidateErr> {
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<W: std::io::Write>(&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::<usize>() {
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<W: std::io::Write>(
&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<W: std::io::Write>(self, opts: Options, w: &mut W) -> error::Result<Answer> {
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<Choice<String>>,
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,
},
}),
)
}
}