use chrono::{Datelike, Duration, NaiveDate};
use std::{
cmp::{max, min},
ops::Add,
};
use crate::{
config::get_configuration,
date_utils::{get_current_date, get_month},
error::{InquireError, InquireResult},
formatter::{self, DateFormatter},
terminal::{get_default_terminal, Terminal},
ui::{date::DateSelectBackend, Backend, Key, KeyModifiers, RenderConfig},
validator::{DateValidator, ErrorMessage, Validation},
};
#[derive(Clone)]
pub struct DateSelect<'a> {
pub message: &'a str,
pub week_start: chrono::Weekday,
pub starting_date: NaiveDate,
pub min_date: Option<NaiveDate>,
pub max_date: Option<NaiveDate>,
pub help_message: Option<&'a str>,
pub vim_mode: bool,
pub formatter: DateFormatter<'a>,
pub validators: Vec<Box<dyn DateValidator>>,
pub render_config: RenderConfig<'a>,
}
impl<'a> DateSelect<'a> {
pub const DEFAULT_FORMATTER: DateFormatter<'a> = formatter::DEFAULT_DATE_FORMATTER;
pub const DEFAULT_VIM_MODE: bool = true;
pub const DEFAULT_HELP_MESSAGE: Option<&'a str> =
Some("arrows to move, with ctrl to move months and years, enter to select");
pub const DEFAULT_VALIDATORS: Vec<Box<dyn DateValidator>> = vec![];
pub const DEFAULT_WEEK_START: chrono::Weekday = chrono::Weekday::Sun;
pub const DEFAULT_MIN_DATE: Option<NaiveDate> = None;
pub const DEFAULT_MAX_DATE: Option<NaiveDate> = None;
pub fn new(message: &'a str) -> Self {
Self {
message,
starting_date: get_current_date(),
min_date: Self::DEFAULT_MIN_DATE,
max_date: Self::DEFAULT_MAX_DATE,
help_message: Self::DEFAULT_HELP_MESSAGE,
vim_mode: Self::DEFAULT_VIM_MODE,
formatter: Self::DEFAULT_FORMATTER,
validators: Self::DEFAULT_VALIDATORS,
week_start: Self::DEFAULT_WEEK_START,
render_config: get_configuration(),
}
}
pub fn with_help_message(mut self, message: &'a str) -> Self {
self.help_message = Some(message);
self
}
pub fn without_help_message(mut self) -> Self {
self.help_message = None;
self
}
pub fn with_default(self, default: NaiveDate) -> Self {
self.with_starting_date(default)
}
pub fn with_week_start(mut self, week_start: chrono::Weekday) -> Self {
self.week_start = week_start;
self
}
pub fn with_min_date(mut self, min_date: NaiveDate) -> Self {
self.min_date = Some(min_date);
self
}
pub fn with_max_date(mut self, max_date: NaiveDate) -> Self {
self.max_date = Some(max_date);
self
}
pub fn with_starting_date(mut self, starting_date: NaiveDate) -> Self {
self.starting_date = starting_date;
self
}
pub fn with_validator<V>(mut self, validator: V) -> Self
where
V: DateValidator + 'static,
{
if self.validators.capacity() == 0 {
self.validators.reserve(5);
}
self.validators.push(Box::new(validator));
self
}
pub fn with_validators(mut self, validators: &[Box<dyn DateValidator>]) -> Self {
for validator in validators {
self.validators.push(validator.clone());
}
self
}
pub fn with_vim_mode(mut self, vim_mode: bool) -> Self {
self.vim_mode = vim_mode;
self
}
pub fn with_formatter(mut self, formatter: DateFormatter<'a>) -> Self {
self.formatter = formatter;
self
}
pub fn with_render_config(mut self, render_config: RenderConfig<'a>) -> Self {
self.render_config = render_config;
self
}
pub fn prompt_skippable(self) -> InquireResult<Option<NaiveDate>> {
match self.prompt() {
Ok(answer) => Ok(Some(answer)),
Err(InquireError::OperationCanceled) => Ok(None),
Err(err) => Err(err),
}
}
pub fn prompt(self) -> InquireResult<NaiveDate> {
let terminal = get_default_terminal()?;
let mut backend = Backend::new(terminal, self.render_config)?;
self.prompt_with_backend(&mut backend)
}
pub(crate) fn prompt_with_backend<T: Terminal>(
self,
backend: &mut Backend<'a, T>,
) -> InquireResult<NaiveDate> {
DateSelectPrompt::new(self)?.prompt(backend)
}
}
struct DateSelectPrompt<'a> {
message: &'a str,
current_date: NaiveDate,
week_start: chrono::Weekday,
min_date: Option<NaiveDate>,
max_date: Option<NaiveDate>,
help_message: Option<&'a str>,
vim_mode: bool,
formatter: DateFormatter<'a>,
validators: Vec<Box<dyn DateValidator>>,
error: Option<ErrorMessage>,
}
impl<'a> DateSelectPrompt<'a> {
fn new(so: DateSelect<'a>) -> InquireResult<Self> {
if let Some(min_date) = so.min_date {
if min_date > so.starting_date {
return Err(InquireError::InvalidConfiguration(
"Min date can not be greater than starting date".into(),
));
}
}
if let Some(max_date) = so.max_date {
if max_date < so.starting_date {
return Err(InquireError::InvalidConfiguration(
"Max date can not be smaller than starting date".into(),
));
}
}
Ok(Self {
message: so.message,
current_date: so.starting_date,
min_date: so.min_date,
max_date: so.max_date,
week_start: so.week_start,
help_message: so.help_message,
vim_mode: so.vim_mode,
formatter: so.formatter,
validators: so.validators,
error: None,
})
}
fn shift_date(&mut self, duration: chrono::Duration) {
self.update_date(self.current_date.add(duration));
}
fn shift_months(&mut self, qty: i32) {
let date = self.current_date;
let years = qty / 12;
let months = qty % 12;
let new_year = date.year() + years;
let cur_month = date.month0() as i32;
let mut new_month = (cur_month + months) % 12;
if new_month < 0 {
new_month += 12;
}
let new_date = date
.with_month0(new_month as u32)
.and_then(|d| d.with_year(new_year));
if let Some(new_date) = new_date {
self.update_date(new_date);
}
}
fn update_date(&mut self, new_date: NaiveDate) {
self.current_date = new_date;
if let Some(min_date) = self.min_date {
self.current_date = max(self.current_date, min_date);
}
if let Some(max_date) = self.max_date {
self.current_date = min(self.current_date, max_date);
}
}
fn on_change(&mut self, key: Key) {
match key {
Key::Up(KeyModifiers::NONE) => self.shift_date(Duration::weeks(-1)),
Key::Char('k', KeyModifiers::NONE) if self.vim_mode => {
self.shift_date(Duration::weeks(-1))
}
Key::Down(KeyModifiers::NONE) | Key::Tab => self.shift_date(Duration::weeks(1)),
Key::Char('j', KeyModifiers::NONE) if self.vim_mode => {
self.shift_date(Duration::weeks(1))
}
Key::Left(KeyModifiers::NONE) => self.shift_date(Duration::days(-1)),
Key::Char('h', KeyModifiers::NONE) if self.vim_mode => {
self.shift_date(Duration::days(-1))
}
Key::Right(KeyModifiers::NONE) => self.shift_date(Duration::days(1)),
Key::Char('l', KeyModifiers::NONE) if self.vim_mode => {
self.shift_date(Duration::days(1))
}
Key::Up(KeyModifiers::CONTROL) => self.shift_months(-12),
Key::Down(KeyModifiers::CONTROL) => self.shift_months(12),
Key::Left(KeyModifiers::CONTROL) => self.shift_months(-1),
Key::Right(KeyModifiers::CONTROL) => self.shift_months(1),
_ => {}
}
}
fn validate_current_answer(&self) -> InquireResult<Validation> {
for validator in &self.validators {
match validator.validate(self.current_date) {
Ok(Validation::Valid) => {}
Ok(Validation::Invalid(msg)) => return Ok(Validation::Invalid(msg)),
Err(err) => return Err(InquireError::Custom(err)),
}
}
Ok(Validation::Valid)
}
fn cur_answer(&self) -> NaiveDate {
self.current_date
}
fn render<B: DateSelectBackend>(&mut self, backend: &mut B) -> InquireResult<()> {
let prompt = &self.message;
backend.frame_setup()?;
if let Some(err) = &self.error {
backend.render_error_message(err)?;
}
backend.render_calendar_prompt(prompt)?;
backend.render_calendar(
get_month(self.current_date.month()),
self.current_date.year(),
self.week_start,
get_current_date(),
self.current_date,
self.min_date,
self.max_date,
)?;
if let Some(help_message) = self.help_message {
backend.render_help_message(help_message)?;
}
backend.frame_finish()?;
Ok(())
}
fn prompt<B: DateSelectBackend>(mut self, backend: &mut B) -> InquireResult<NaiveDate> {
let final_answer: NaiveDate;
loop {
self.render(backend)?;
let key = backend.read_key()?;
match key {
Key::Interrupt => interrupt_prompt!(),
Key::Cancel => cancel_prompt!(backend, self.message),
Key::Submit | Key::Char(' ', _) => match self.validate_current_answer()? {
Validation::Valid => {
final_answer = self.cur_answer();
break;
}
Validation::Invalid(msg) => {
self.error = Some(msg);
}
},
key => self.on_change(key),
}
}
let formatted = (self.formatter)(final_answer);
finish_prompt_with_answer!(backend, self.message, &formatted, final_answer);
}
}
#[cfg(test)]
#[cfg(feature = "crossterm")]
mod test {
use crate::{
date_utils::get_current_date,
terminal::crossterm::CrosstermTerminal,
ui::{Backend, RenderConfig},
validator::Validation,
DateSelect,
};
use chrono::NaiveDate;
use crossterm::event::{KeyCode, KeyEvent};
fn default<'a>() -> DateSelect<'a> {
DateSelect::new("Question?")
}
macro_rules! date_test {
($name:ident,$input:expr,$output:expr) => {
date_test! {$name, $input, $output, default()}
};
($name:ident,$input:expr,$output:expr,$prompt:expr) => {
#[test]
fn $name() {
let read: Vec<KeyEvent> = $input.into_iter().map(KeyEvent::from).collect();
let mut read = read.iter();
let mut write: Vec<u8> = Vec::new();
let terminal = CrosstermTerminal::new_with_io(&mut write, &mut read);
let mut backend = Backend::new(terminal, RenderConfig::default()).unwrap();
let ans = $prompt.prompt_with_backend(&mut backend).unwrap();
assert_eq!($output, ans);
}
};
}
date_test!(today_date, vec![KeyCode::Char('q')], get_current_date());
date_test!(
custom_default_date,
vec![KeyCode::Char('q')],
NaiveDate::from_ymd(2021, 1, 9),
DateSelect::new("Date").with_default(NaiveDate::from_ymd(2021, 1, 9))
);
#[test]
fn closure_validator() {
let read: Vec<KeyEvent> = vec![KeyCode::Char('q'), KeyCode::Left, KeyCode::Char('q')]
.into_iter()
.map(KeyEvent::from)
.collect();
let mut read = read.iter();
let today_date = get_current_date();
let validator = move |d| {
if today_date > d {
Ok(Validation::Valid)
} else {
Ok(Validation::Invalid("Date must be in the past".into()))
}
};
let mut write: Vec<u8> = Vec::new();
let terminal = CrosstermTerminal::new_with_io(&mut write, &mut read);
let mut backend = Backend::new(terminal, RenderConfig::default()).unwrap();
let ans = DateSelect::new("Question")
.with_validator(validator)
.prompt_with_backend(&mut backend)
.unwrap();
assert_eq!(today_date.pred(), ans);
}
}