From 77e14aac28440a473cc7d8a969591e6daef6c8e8 Mon Sep 17 00:00:00 2001 From: SIMULATAN Date: Mon, 18 Sep 2023 14:51:18 +0200 Subject: [PATCH 1/5] Introduce MultiSelectPlus --- src/lib.rs | 2 +- src/prompts/mod.rs | 1 + src/prompts/multi_select_plus.rs | 375 +++++++++++++++++++++++++++++++ 3 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 src/prompts/multi_select_plus.rs diff --git a/src/lib.rs b/src/lib.rs index 68dfd2b2..7dd40e8b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,7 +49,7 @@ pub use prompts::fuzzy_select::FuzzySelect; #[cfg(feature = "password")] pub use prompts::password::Password; pub use prompts::{ - confirm::Confirm, input::Input, multi_select::MultiSelect, select::Select, sort::Sort, + confirm::Confirm, input::Input, multi_select::MultiSelect, multi_select_plus::MultiSelectPlus, multi_select_plus::MultiSelectPlusItem, select::Select, sort::Sort, }; #[cfg(feature = "completion")] diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index 1c131855..382ebec3 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -3,6 +3,7 @@ pub mod confirm; pub mod input; pub mod multi_select; +pub mod multi_select_plus; pub mod select; pub mod sort; diff --git a/src/prompts/multi_select_plus.rs b/src/prompts/multi_select_plus.rs new file mode 100644 index 00000000..f50beec2 --- /dev/null +++ b/src/prompts/multi_select_plus.rs @@ -0,0 +1,375 @@ +use std::{io, ops::Rem}; + +use console::{Key, Term}; + +use crate::{ + Paging, + Result, theme::{render::TermThemeRenderer, SimpleTheme, Theme}, +}; + +/// Renders a multi select prompt. +/// +/// ## Example +/// +/// ```rust,no_run +/// use dialoguer::MultiSelectPlus; +/// +/// fn main() { +/// use dialoguer::MultiSelectPlusItem; +/// let items = vec![ +/// MultiSelectPlusItem { name: "Foo", summary_text: "Foo", checked: false }, +/// MultiSelectPlusItem { name: "Bar (more details here)", summary_text: "Bar", checked: true }, +/// ]; +/// +/// let selection = MultiSelectPlus::new() +/// .with_prompt("What do you choose?") +/// .items(items) +/// .interact() +/// .unwrap(); +/// +/// println!("You chose:"); +/// +/// for i in selection { +/// println!("{}", items[i]); +/// } +/// } +/// ``` +#[derive(Clone)] +pub struct MultiSelectPlus<'a, N: ToString + Clone, S: ToString + Clone> { + items: Vec>, + prompt: Option, + report: bool, + clear: bool, + max_length: Option, + theme: &'a dyn Theme, +} + +#[derive(Clone)] +pub struct MultiSelectPlusItem { + pub name: N, + pub summary_text: S, + pub checked: bool, +} + +impl MultiSelectPlusItem { + pub fn name(&self) -> &N { + &self.name + } + + pub fn summary_text(&self) -> &S { + &self.summary_text + } + + pub fn checked(&self) -> bool { + self.checked + } +} + +impl Default for MultiSelectPlus<'static, N, S> { + fn default() -> Self { + Self::new() + } +} + +impl MultiSelectPlus<'static, N, S> { + /// Creates a multi select prompt with default theme. + pub fn new() -> Self { + Self::with_theme(&SimpleTheme) + } +} + +impl MultiSelectPlus<'_, N, S> { + /// Sets the clear behavior of the menu. + /// + /// The default is to clear the menu. + pub fn clear(mut self, val: bool) -> Self { + self.clear = val; + self + } + + /// Sets an optional max length for a page + /// + /// Max length is disabled by None + pub fn max_length(mut self, val: usize) -> Self { + // Paging subtracts two from the capacity, paging does this to + // make an offset for the page indicator. So to make sure that + // we can show the intended amount of items we need to add two + // to our value. + self.max_length = Some(val + 2); + self + } + + /// Add a single item to the selector. + pub fn item(mut self, item: MultiSelectPlusItem) -> Self { + self.items.push(item); + self + } + + /// Adds multiple items to the selector. + pub fn items(mut self, items: Vec>) -> Self { + self.items.extend(items); + self + } + + /// Prefaces the menu with a prompt. + /// + /// By default, when a prompt is set the system also prints out a confirmation after + /// the selection. You can opt-out of this with [`report`](Self::report). + pub fn with_prompt>(mut self, prompt: T) -> Self { + self.prompt = Some(prompt.into()); + self + } + + /// Indicates whether to report the selected values after interaction. + /// + /// The default is to report the selections. + pub fn report(mut self, val: bool) -> Self { + self.report = val; + self + } + + /// Enables user interaction and returns the result. + /// + /// The user can select the items with the 'Space' bar and on 'Enter' the indices of selected items will be returned. + /// The dialog is rendered on stderr. + /// Result contains `Vec` if user hit 'Enter'. + /// This unlike [`interact_opt`](Self::interact_opt) does not allow to quit with 'Esc' or 'q'. + #[inline] + pub fn interact(self) -> Result> { + self.interact_on(&Term::stderr()) + } + + /// Enables user interaction and returns the result. + /// + /// The user can select the items with the 'Space' bar and on 'Enter' the indices of selected items will be returned. + /// The dialog is rendered on stderr. + /// Result contains `Some(Vec)` if user hit 'Enter' or `None` if user cancelled with 'Esc' or 'q'. + /// + /// ## Example + /// + /// ```rust,no_run + /// use dialoguer::MultiSelectPlus; + /// use dialoguer::MultiSelectPlusItem; + /// + /// fn main() { + /// let items = vec![ + /// MultiSelectPlusItem { name: "Foo", summary_text: "Foo", checked: false }, + /// MultiSelectPlusItem { name: "Bar (more details here)", summary_text: "Bar", checked: true }, + /// ]; + /// + /// let ordered = MultiSelectPlus::new() + /// .items(items) + /// .interact_opt() + /// .unwrap(); + /// + /// match ordered { + /// Some(positions) => { + /// println!("You chose:"); + /// + /// for i in positions { + /// println!("{}", items[i]); + /// } + /// } + /// None => println!("You did not choose anything.") + /// } + /// } + /// ``` + #[inline] + pub fn interact_opt(self) -> Result>> { + self.interact_on_opt(&Term::stderr()) + } + + /// Like [`interact`](Self::interact) but allows a specific terminal to be set. + #[inline] + pub fn interact_on(self, term: &Term) -> Result> { + Ok(self + ._interact_on(term, false)? + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Quit not allowed in this case"))?) + } + + /// Like [`interact_opt`](Self::interact_opt) but allows a specific terminal to be set. + #[inline] + pub fn interact_on_opt(self, term: &Term) -> Result>> { + self._interact_on(term, true) + } + + fn _interact_on(mut self, term: &Term, allow_quit: bool) -> Result>> { + if !term.is_term() { + return Err(io::Error::new(io::ErrorKind::NotConnected, "not a terminal").into()); + } + + if self.items.is_empty() { + return Err(io::Error::new( + io::ErrorKind::Other, + "Empty list of items given to `MultiSelect`", + ))?; + } + + let mut paging = Paging::new(term, self.items.len(), self.max_length); + let mut render = TermThemeRenderer::new(term, self.theme); + let mut sel = 0; + + let size_vec = self.items + .iter() + .flat_map(|i| i.name.to_string().split('\n').map(|s| s.len()).collect::>()) + .collect::>(); + + term.hide_cursor()?; + + loop { + if let Some(ref prompt) = self.prompt { + paging + .render_prompt(|paging_info| render.multi_select_prompt(prompt, paging_info))?; + } + + // clone to prevent mutating while waiting for input + let mut items = self.items.to_vec(); + + for (idx, item) in items + .iter() + .enumerate() + .skip(paging.current_page * paging.capacity) + .take(paging.capacity) + { + render.multi_select_prompt_item(item.name().to_string().as_str(), item.checked, sel == idx)?; + } + + term.flush()?; + + match term.read_key()? { + Key::ArrowDown | Key::Tab | Key::Char('j') => { + if sel == !0 { + sel = 0; + } else { + sel = (sel as u64 + 1).rem(self.items.len() as u64) as usize; + } + } + Key::ArrowUp | Key::BackTab | Key::Char('k') => { + if sel == !0 { + sel = self.items.len() - 1; + } else { + sel = ((sel as i64 - 1 + self.items.len() as i64) + % (self.items.len() as i64)) as usize; + } + } + Key::ArrowLeft | Key::Char('h') => { + if paging.active { + sel = paging.previous_page(); + } + } + Key::ArrowRight | Key::Char('l') => { + if paging.active { + sel = paging.next_page(); + } + } + Key::Char(' ') => { + items[sel].checked = !items[sel].checked; + self.items = items; + } + Key::Char('a') => { + if items.iter().all(|item| item.checked) { + items.iter_mut().for_each(|item| item.checked = false); + } else { + items.iter_mut().for_each(|item| item.checked = true); + } + } + Key::Escape | Key::Char('q') => { + if allow_quit { + if self.clear { + render.clear()?; + } else { + term.clear_last_lines(paging.capacity)?; + } + + term.show_cursor()?; + term.flush()?; + + return Ok(None); + } + } + Key::Enter => { + if self.clear { + render.clear()?; + } + + if let Some(ref prompt) = self.prompt { + if self.report { + let selections: Vec<_> = items + .iter() + .enumerate() + .filter_map(|(_, item)| { + if item.checked { + Some(item.summary_text.to_string()) + } else { + None + } + }) + .collect(); + + render.multi_select_prompt_selection(prompt, &selections.iter().map(|s| s.as_str()).collect::>())?; + } + } + + term.show_cursor()?; + term.flush()?; + + return Ok(Some( + items + .into_iter() + .enumerate() + .filter_map(|(idx, item)| if item.checked { Some(idx) } else { None }) + .collect(), + )); + } + _ => {} + } + + paging.update(sel)?; + + if paging.active { + render.clear()?; + } else { + render.clear_preserve_prompt(&size_vec)?; + } + } + } +} + +impl<'a, N: ToString + Clone, S: ToString + Clone> MultiSelectPlus<'a, N, S> { + /// Creates a multi select prompt with a specific theme. + /// + /// ## Example + /// + /// ```rust,no_run + /// use dialoguer::{theme::ColorfulTheme, MultiSelect}; + /// + /// fn main() { + /// let selection = MultiSelect::with_theme(&ColorfulTheme::default()) + /// .items(&["foo", "bar", "baz"]) + /// .interact() + /// .unwrap(); + /// } + /// ``` + pub fn with_theme(theme: &'a dyn Theme) -> Self { + Self { + items: vec![], + clear: true, + prompt: None, + report: true, + max_length: None, + theme, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clone() { + let multi_select = MultiSelectPlus::::new().with_prompt("Select your favorite(s)"); + + let _ = multi_select.clone(); + } +} From 026d257f0766164bb5dc7daedb7b3f5dbf67fec8 Mon Sep 17 00:00:00 2001 From: SIMULATAN Date: Wed, 27 Sep 2023 10:41:29 +0200 Subject: [PATCH 2/5] Support different selection types --- src/lib.rs | 4 +- src/prompts/multi_select_plus.rs | 143 +++++++++++++++++++++++-------- src/theme/colorful.rs | 30 +++++++ src/theme/mod.rs | 25 +++++- src/theme/render.rs | 13 ++- 5 files changed, 175 insertions(+), 40 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7dd40e8b..41ad0baa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,7 +49,9 @@ pub use prompts::fuzzy_select::FuzzySelect; #[cfg(feature = "password")] pub use prompts::password::Password; pub use prompts::{ - confirm::Confirm, input::Input, multi_select::MultiSelect, multi_select_plus::MultiSelectPlus, multi_select_plus::MultiSelectPlusItem, select::Select, sort::Sort, + confirm::Confirm, input::Input, multi_select::MultiSelect, multi_select_plus::MultiSelectPlus, + multi_select_plus::MultiSelectPlusItem, multi_select_plus::MultiSelectPlusStatus, + select::Select, sort::Sort, }; #[cfg(feature = "completion")] diff --git a/src/prompts/multi_select_plus.rs b/src/prompts/multi_select_plus.rs index f50beec2..ea049a11 100644 --- a/src/prompts/multi_select_plus.rs +++ b/src/prompts/multi_select_plus.rs @@ -3,8 +3,8 @@ use std::{io, ops::Rem}; use console::{Key, Term}; use crate::{ - Paging, - Result, theme::{render::TermThemeRenderer, SimpleTheme, Theme}, + theme::{render::TermThemeRenderer, SimpleTheme, Theme}, + Paging, Result, }; /// Renders a multi select prompt. @@ -15,10 +15,26 @@ use crate::{ /// use dialoguer::MultiSelectPlus; /// /// fn main() { -/// use dialoguer::MultiSelectPlusItem; +/// use dialoguer::{MultiSelectPlusItem, MultiSelectPlusStatus}; /// let items = vec![ -/// MultiSelectPlusItem { name: "Foo", summary_text: "Foo", checked: false }, -/// MultiSelectPlusItem { name: "Bar (more details here)", summary_text: "Bar", checked: true }, +/// MultiSelectPlusItem { +/// name: String::from("Foo"), +/// summary_text: String::from("Foo"), +/// status: MultiSelectPlusStatus::UNCHECKED +/// }, +/// MultiSelectPlusItem { +/// name: String::from("Bar (more details here)"), +/// summary_text: String::from("Bar"), +/// status: MultiSelectPlusStatus::CHECKED +/// }, +/// MultiSelectPlusItem { +/// name: String::from("Baz"), +/// summary_text: String::from("Baz"), +/// status: MultiSelectPlusStatus { +/// checked: false, +/// symbol: "-" +/// } +/// } /// ]; /// /// let selection = MultiSelectPlus::new() @@ -35,8 +51,10 @@ use crate::{ /// } /// ``` #[derive(Clone)] -pub struct MultiSelectPlus<'a, N: ToString + Clone, S: ToString + Clone> { - items: Vec>, +pub struct MultiSelectPlus<'a> { + items: Vec, + checked_status: MultiSelectPlusStatus, + unchecked_status: MultiSelectPlusStatus, prompt: Option, report: bool, clear: bool, @@ -45,40 +63,55 @@ pub struct MultiSelectPlus<'a, N: ToString + Clone, S: ToString + Clone> { } #[derive(Clone)] -pub struct MultiSelectPlusItem { - pub name: N, - pub summary_text: S, - pub checked: bool, +pub struct MultiSelectPlusItem { + pub name: String, + pub summary_text: String, + pub status: MultiSelectPlusStatus, } -impl MultiSelectPlusItem { - pub fn name(&self) -> &N { +impl MultiSelectPlusItem { + pub fn name(&self) -> &String { &self.name } - pub fn summary_text(&self) -> &S { + pub fn summary_text(&self) -> &String { &self.summary_text } - pub fn checked(&self) -> bool { - self.checked + pub fn checked(&self) -> &MultiSelectPlusStatus { + &self.status } } -impl Default for MultiSelectPlus<'static, N, S> { +#[derive(Clone)] +pub struct MultiSelectPlusStatus { + pub checked: bool, + pub symbol: &'static str, +} + +impl MultiSelectPlusStatus { + pub const fn new(checked: bool, symbol: &'static str) -> Self { + Self { checked, symbol } + } + + pub const CHECKED: Self = Self::new(true, "X"); + pub const UNCHECKED: Self = Self::new(false, " "); +} + +impl Default for MultiSelectPlus<'static> { fn default() -> Self { Self::new() } } -impl MultiSelectPlus<'static, N, S> { +impl MultiSelectPlus<'static> { /// Creates a multi select prompt with default theme. pub fn new() -> Self { Self::with_theme(&SimpleTheme) } } -impl MultiSelectPlus<'_, N, S> { +impl MultiSelectPlus<'_> { /// Sets the clear behavior of the menu. /// /// The default is to clear the menu. @@ -100,13 +133,13 @@ impl MultiSelectPlus<'_, N, S> { } /// Add a single item to the selector. - pub fn item(mut self, item: MultiSelectPlusItem) -> Self { + pub fn item(mut self, item: MultiSelectPlusItem) -> Self { self.items.push(item); self } /// Adds multiple items to the selector. - pub fn items(mut self, items: Vec>) -> Self { + pub fn items(mut self, items: Vec) -> Self { self.items.extend(items); self } @@ -150,11 +183,28 @@ impl MultiSelectPlus<'_, N, S> { /// ```rust,no_run /// use dialoguer::MultiSelectPlus; /// use dialoguer::MultiSelectPlusItem; + /// use dialoguer::MultiSelectPlusStatus; /// /// fn main() { /// let items = vec![ - /// MultiSelectPlusItem { name: "Foo", summary_text: "Foo", checked: false }, - /// MultiSelectPlusItem { name: "Bar (more details here)", summary_text: "Bar", checked: true }, + /// MultiSelectPlusItem { + /// name: String::from("Foo"), + /// summary_text: String::from("Foo"), + /// status: MultiSelectPlusStatus::UNCHECKED + /// }, + /// MultiSelectPlusItem { + /// name: String::from("Bar (more details here)"), + /// summary_text: String::from("Bar"), + /// status: MultiSelectPlusStatus::CHECKED + /// }, + /// MultiSelectPlusItem { + /// name: String::from("Baz"), + /// summary_text: String::from("Baz"), + /// status: MultiSelectPlusStatus { + /// checked: false, + /// symbol: "-" + /// } + /// } /// ]; /// /// let ordered = MultiSelectPlus::new() @@ -209,9 +259,16 @@ impl MultiSelectPlus<'_, N, S> { let mut render = TermThemeRenderer::new(term, self.theme); let mut sel = 0; - let size_vec = self.items + let size_vec = self + .items .iter() - .flat_map(|i| i.name.to_string().split('\n').map(|s| s.len()).collect::>()) + .flat_map(|i| { + i.name + .to_string() + .split('\n') + .map(|s| s.len()) + .collect::>() + }) .collect::>(); term.hide_cursor()?; @@ -231,7 +288,7 @@ impl MultiSelectPlus<'_, N, S> { .skip(paging.current_page * paging.capacity) .take(paging.capacity) { - render.multi_select_prompt_item(item.name().to_string().as_str(), item.checked, sel == idx)?; + render.multi_select_plus_prompt_item(item, sel == idx)?; } term.flush()?; @@ -263,15 +320,24 @@ impl MultiSelectPlus<'_, N, S> { } } Key::Char(' ') => { - items[sel].checked = !items[sel].checked; + items[sel].status = if items[sel].status.checked { + self.unchecked_status.clone() + } else { + self.checked_status.clone() + }; self.items = items; } Key::Char('a') => { - if items.iter().all(|item| item.checked) { - items.iter_mut().for_each(|item| item.checked = false); + if items.iter().all(|item| item.status.checked) { + items + .iter_mut() + .for_each(|item| item.status = self.unchecked_status.clone()); } else { - items.iter_mut().for_each(|item| item.checked = true); + items + .iter_mut() + .for_each(|item| item.status = self.checked_status.clone()); } + self.items = items; } Key::Escape | Key::Char('q') => { if allow_quit { @@ -298,7 +364,7 @@ impl MultiSelectPlus<'_, N, S> { .iter() .enumerate() .filter_map(|(_, item)| { - if item.checked { + if item.status.checked { Some(item.summary_text.to_string()) } else { None @@ -306,7 +372,10 @@ impl MultiSelectPlus<'_, N, S> { }) .collect(); - render.multi_select_prompt_selection(prompt, &selections.iter().map(|s| s.as_str()).collect::>())?; + render.multi_select_prompt_selection( + prompt, + &selections.iter().map(|s| s.as_str()).collect::>(), + )?; } } @@ -317,7 +386,9 @@ impl MultiSelectPlus<'_, N, S> { items .into_iter() .enumerate() - .filter_map(|(idx, item)| if item.checked { Some(idx) } else { None }) + .filter_map( + |(idx, item)| if item.status.checked { Some(idx) } else { None }, + ) .collect(), )); } @@ -335,7 +406,7 @@ impl MultiSelectPlus<'_, N, S> { } } -impl<'a, N: ToString + Clone, S: ToString + Clone> MultiSelectPlus<'a, N, S> { +impl<'a> MultiSelectPlus<'a> { /// Creates a multi select prompt with a specific theme. /// /// ## Example @@ -353,6 +424,8 @@ impl<'a, N: ToString + Clone, S: ToString + Clone> MultiSelectPlus<'a, N, S> { pub fn with_theme(theme: &'a dyn Theme) -> Self { Self { items: vec![], + unchecked_status: MultiSelectPlusStatus::UNCHECKED, + checked_status: MultiSelectPlusStatus::CHECKED, clear: true, prompt: None, report: true, @@ -368,7 +441,7 @@ mod tests { #[test] fn test_clone() { - let multi_select = MultiSelectPlus::::new().with_prompt("Select your favorite(s)"); + let multi_select = MultiSelectPlus::new().with_prompt("Select your favorite(s)"); let _ = multi_select.clone(); } diff --git a/src/theme/colorful.rs b/src/theme/colorful.rs index 97569509..40c03d51 100644 --- a/src/theme/colorful.rs +++ b/src/theme/colorful.rs @@ -1,5 +1,6 @@ use std::fmt; +use crate::MultiSelectPlusItem; use console::{style, Style, StyledObject}; #[cfg(feature = "fuzzy-select")] use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; @@ -321,6 +322,35 @@ impl Theme for ColorfulTheme { write!(f, "{} {}", details.0, details.1) } + /// Formats a multi select plus prompt item. + fn format_multi_select_plus_prompt_item( + &self, + f: &mut dyn fmt::Write, + item: &MultiSelectPlusItem, + active: bool, + ) -> fmt::Result { + let details = match (item.status.checked, active) { + (true, true) => ( + &self.checked_item_prefix, + self.active_item_style.apply_to(item.name()), + ), + (true, false) => ( + &self.checked_item_prefix, + self.inactive_item_style.apply_to(item.name()), + ), + (false, true) => ( + &self.unchecked_item_prefix, + self.active_item_style.apply_to(item.name()), + ), + (false, false) => ( + &self.unchecked_item_prefix, + self.inactive_item_style.apply_to(item.name()), + ), + }; + + write!(f, "{} {}", details.0, details.1) + } + /// Formats a sort prompt item. fn format_sort_prompt_item( &self, diff --git a/src/theme/mod.rs b/src/theme/mod.rs index d22001cf..18813b16 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -6,13 +6,15 @@ use console::style; #[cfg(feature = "fuzzy-select")] use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; +pub use colorful::ColorfulTheme; +pub use simple::SimpleTheme; + +use crate::MultiSelectPlusItem; + mod colorful; pub(crate) mod render; mod simple; -pub use colorful::ColorfulTheme; -pub use simple::SimpleTheme; - /// Implements a theme for dialoguer. pub trait Theme { /// Formats a prompt. @@ -196,6 +198,23 @@ pub trait Theme { ) } + fn format_multi_select_plus_prompt_item( + &self, + f: &mut dyn fmt::Write, + item: &MultiSelectPlusItem, + active: bool, + ) -> fmt::Result { + write!( + f, + "{} {}", + match active { + true => format!("> [{}]", item.status.symbol), + false => format!(" [{}]", item.status.symbol), + }, + item.name + ) + } + /// Formats a sort prompt item. fn format_sort_prompt_item( &self, diff --git a/src/theme/render.rs b/src/theme/render.rs index e6f3addf..dce521ca 100644 --- a/src/theme/render.rs +++ b/src/theme/render.rs @@ -4,7 +4,7 @@ use console::{measure_text_width, Term}; #[cfg(feature = "fuzzy-select")] use fuzzy_matcher::skim::SkimMatcherV2; -use crate::{theme::Theme, Result}; +use crate::{theme::Theme, MultiSelectPlusItem, Result}; /// Helper struct to conveniently render a theme. pub(crate) struct TermThemeRenderer<'a> { @@ -210,6 +210,17 @@ impl<'a> TermThemeRenderer<'a> { }) } + pub fn multi_select_plus_prompt_item( + &mut self, + item: &MultiSelectPlusItem, + active: bool, + ) -> Result { + self.write_formatted_line(|this, buf| { + this.theme + .format_multi_select_plus_prompt_item(buf, item, active) + }) + } + pub fn sort_prompt(&mut self, prompt: &str, paging_info: Option<(usize, usize)>) -> Result { self.write_formatted_prompt(|this, buf| { this.theme.format_sort_prompt(buf, prompt)?; From 412baec36b38700517ab95ca6b78f0b6de75b92b Mon Sep 17 00:00:00 2001 From: SIMULATAN Date: Thu, 28 Sep 2023 07:33:57 +0200 Subject: [PATCH 3/5] Implement select callback --- src/lib.rs | 2 +- src/prompts/multi_select_plus.rs | 36 +++++++++++++++++--------------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 41ad0baa..a241b5b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,7 +50,7 @@ pub use prompts::fuzzy_select::FuzzySelect; pub use prompts::password::Password; pub use prompts::{ confirm::Confirm, input::Input, multi_select::MultiSelect, multi_select_plus::MultiSelectPlus, - multi_select_plus::MultiSelectPlusItem, multi_select_plus::MultiSelectPlusStatus, + multi_select_plus::MultiSelectPlusItem, multi_select_plus::MultiSelectPlusStatus, multi_select_plus::SelectCallback, select::Select, sort::Sort, }; diff --git a/src/prompts/multi_select_plus.rs b/src/prompts/multi_select_plus.rs index ea049a11..d4f44b54 100644 --- a/src/prompts/multi_select_plus.rs +++ b/src/prompts/multi_select_plus.rs @@ -50,11 +50,11 @@ use crate::{ /// } /// } /// ``` -#[derive(Clone)] pub struct MultiSelectPlus<'a> { items: Vec, checked_status: MultiSelectPlusStatus, unchecked_status: MultiSelectPlusStatus, + select_callback: Option>>, prompt: Option, report: bool, clear: bool, @@ -83,7 +83,7 @@ impl MultiSelectPlusItem { } } -#[derive(Clone)] +#[derive(Clone, PartialEq)] pub struct MultiSelectPlusStatus { pub checked: bool, pub symbol: &'static str, @@ -104,14 +104,17 @@ impl Default for MultiSelectPlus<'static> { } } -impl MultiSelectPlus<'static> { +impl <'a> MultiSelectPlus<'a> { /// Creates a multi select prompt with default theme. pub fn new() -> Self { Self::with_theme(&SimpleTheme) } } -impl MultiSelectPlus<'_> { +pub type SelectCallback<'a> = dyn Fn(&MultiSelectPlusItem, &Vec) -> Option> + 'a; + + +impl<'a> MultiSelectPlus<'a> { /// Sets the clear behavior of the menu. /// /// The default is to clear the menu. @@ -132,6 +135,11 @@ impl MultiSelectPlus<'_> { self } + pub fn with_select_callback(mut self, val: Box>) -> Self { + self.select_callback = Some(val); + self + } + /// Add a single item to the selector. pub fn item(mut self, item: MultiSelectPlusItem) -> Self { self.items.push(item); @@ -325,7 +333,12 @@ impl MultiSelectPlus<'_> { } else { self.checked_status.clone() }; - self.items = items; + // if the callback exists, try getting a value from it + // if nothing is returned from the first step, use the `items` as a fallback + self.items = self.select_callback.as_ref() + .and_then(|callback| callback(&items[sel], &items)) + .unwrap_or(items) + } Key::Char('a') => { if items.iter().all(|item| item.status.checked) { @@ -426,6 +439,7 @@ impl<'a> MultiSelectPlus<'a> { items: vec![], unchecked_status: MultiSelectPlusStatus::UNCHECKED, checked_status: MultiSelectPlusStatus::CHECKED, + select_callback: None, clear: true, prompt: None, report: true, @@ -434,15 +448,3 @@ impl<'a> MultiSelectPlus<'a> { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_clone() { - let multi_select = MultiSelectPlus::new().with_prompt("Select your favorite(s)"); - - let _ = multi_select.clone(); - } -} From 723783cc5c567f0f23781ecea4dd3bfbe8cad2b9 Mon Sep 17 00:00:00 2001 From: SIMULATAN Date: Mon, 19 Feb 2024 14:20:59 +0100 Subject: [PATCH 4/5] Fix `size_vec` using wrong field, document `SelectCallback` --- src/prompts/multi_select_plus.rs | 39 +++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/src/prompts/multi_select_plus.rs b/src/prompts/multi_select_plus.rs index d4f44b54..d87ee269 100644 --- a/src/prompts/multi_select_plus.rs +++ b/src/prompts/multi_select_plus.rs @@ -111,6 +111,10 @@ impl <'a> MultiSelectPlus<'a> { } } +/// A callback that can be used to modify the items in the multi select prompt. +/// Executed between the selection of an item and the rendering of the prompt. +/// * `item` - The item that was selected +/// * `items` - The current list of items pub type SelectCallback<'a> = dyn Fn(&MultiSelectPlusItem, &Vec) -> Option> + 'a; @@ -270,13 +274,12 @@ impl<'a> MultiSelectPlus<'a> { let size_vec = self .items .iter() - .flat_map(|i| { - i.name - .to_string() + .flat_map(|i| + i.summary_text .split('\n') .map(|s| s.len()) .collect::>() - }) + ) .collect::>(); term.hide_cursor()?; @@ -400,7 +403,7 @@ impl<'a> MultiSelectPlus<'a> { .into_iter() .enumerate() .filter_map( - |(idx, item)| if item.status.checked { Some(idx) } else { None }, + |(idx, item)| if item.status.checked { Some(idx) } else { None } ) .collect(), )); @@ -425,11 +428,31 @@ impl<'a> MultiSelectPlus<'a> { /// ## Example /// /// ```rust,no_run - /// use dialoguer::{theme::ColorfulTheme, MultiSelect}; + /// use dialoguer::{theme::ColorfulTheme, MultiSelectPlus, MultiSelectPlusItem, MultiSelectPlusStatus}; /// /// fn main() { - /// let selection = MultiSelect::with_theme(&ColorfulTheme::default()) - /// .items(&["foo", "bar", "baz"]) + /// let items = vec![ + /// MultiSelectPlusItem { + /// name: String::from("Foo"), + /// summary_text: String::from("Foo"), + /// status: MultiSelectPlusStatus::UNCHECKED + /// }, + /// MultiSelectPlusItem { + /// name: String::from("Bar (more details here)"), + /// summary_text: String::from("Bar"), + /// status: MultiSelectPlusStatus::CHECKED + /// }, + /// MultiSelectPlusItem { + /// name: String::from("Baz"), + /// summary_text: String::from("Baz"), + /// status: MultiSelectPlusStatus { + /// checked: false, + /// symbol: "-" + /// } + /// } + /// ]; + /// let selection = MultiSelectPlus::with_theme(&ColorfulTheme::default()) + /// .items(items) /// .interact() /// .unwrap(); /// } From 5c0bd9a7702822ef119164ee9646d0bd686a0772 Mon Sep 17 00:00:00 2001 From: SIMULATAN Date: Mon, 19 Feb 2024 14:37:34 +0100 Subject: [PATCH 5/5] MultiSelectPlus: many items by iterator instead of slice Aligns MultiSelectPlus with b5b378093c7e7dacdde7a54451554cb70713c949 --- src/prompts/multi_select_plus.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/prompts/multi_select_plus.rs b/src/prompts/multi_select_plus.rs index d87ee269..9d30a902 100644 --- a/src/prompts/multi_select_plus.rs +++ b/src/prompts/multi_select_plus.rs @@ -151,7 +151,10 @@ impl<'a> MultiSelectPlus<'a> { } /// Adds multiple items to the selector. - pub fn items(mut self, items: Vec) -> Self { + pub fn items(mut self, items: I) -> Self + where + I: IntoIterator + { self.items.extend(items); self }