diff --git a/Cargo.toml b/Cargo.toml index 058b15d..08c2f62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ fuzzy-select = ["fuzzy-matcher"] history = [] password = ["zeroize"] completion = [] +folder-select = [] [dependencies] console = "0.15.0" @@ -52,6 +53,10 @@ required-features = ["history"] name = "completion" required-features = ["completion"] +[[example]] +name = "folder_select" +required-features = ["folder-select"] + [workspace.metadata.workspaces] no_individual_tags = true diff --git a/examples/folder_select.rs b/examples/folder_select.rs new file mode 100644 index 0000000..4a66fd7 --- /dev/null +++ b/examples/folder_select.rs @@ -0,0 +1,20 @@ +use dialoguer::{theme::ColorfulTheme, FolderSelect}; + +fn main() { + let selection = FolderSelect::with_theme(&ColorfulTheme::default()) + .with_prompt("Select some sobfolder from /tmp") + .folder("/tmp") + .interact() + .unwrap(); + + println!("Folder you selected: {}", selection); + + let selection = FolderSelect::with_theme(&ColorfulTheme::default()) + .with_prompt("Select some file from /tmp") + .folder("/tmp") + .file(true) + .interact() + .unwrap(); + + println!("File you selected: {}", selection); +} diff --git a/src/lib.rs b/src/lib.rs index 68dfd2b..02d8f01 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,6 +52,9 @@ pub use prompts::{ confirm::Confirm, input::Input, multi_select::MultiSelect, select::Select, sort::Sort, }; +#[cfg(feature = "folder-select")] +pub use prompts::folder_select::FolderSelect; + #[cfg(feature = "completion")] mod completion; #[cfg(feature = "editor")] diff --git a/src/prompts/folder_select.rs b/src/prompts/folder_select.rs new file mode 100644 index 0000000..a8d361f --- /dev/null +++ b/src/prompts/folder_select.rs @@ -0,0 +1,402 @@ +use std::{io, ops::Rem}; + +use console::{Key, Term}; + +use crate::{ + theme::{render::TermThemeRenderer, SimpleTheme, Theme}, + Paging, Result, +}; + +#[derive(Clone)] +pub struct FolderSelect<'a> { + default: usize, + items: Vec, + prompt: Option, + report: bool, + clear: bool, + file: bool, + theme: &'a dyn Theme, + max_length: Option, + current_folder: String, +} + +impl Default for FolderSelect<'static> { + fn default() -> Self { + Self::new() + } +} + +impl FolderSelect<'static> { + /// Creates a folder_select prompt with default theme. + pub fn new() -> Self { + Self::with_theme(&SimpleTheme) + } +} + +impl FolderSelect<'_> { + /// Indicates whether select menu should be erased from the screen after interaction. + /// + /// The default is to clear the menu. + pub fn clear(mut self, val: bool) -> Self { + self.clear = val; + self + } + + /// Indicates whether select should show both files and folder. + /// + /// The default is to show only folders. + pub fn file(mut self, val: bool) -> Self { + self.file = 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 + } + + /// Sets the select 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: S) -> Self { + self.prompt = Some(prompt.into()); + self.report = true; + self + } + + /// Sets the starting folder + /// + pub fn folder(mut self, folder: T) -> Self { + self.current_folder = folder.to_string(); + self + } + + /// Processes the current folder to populate the items list for selection. + /// + /// This function reads the contents of the current folder, categorizes them into directories and files, + /// and formats them according to the selected theme. The items list is then sorted and returned. + /// + /// # Panics + /// + /// This function panics if it fails to read the current folder. + fn process_folder(mut self) -> Self { + self.items.clear(); + let current_folder = std::path::PathBuf::from(&self.current_folder); + + // Add current directory to the items list + self.items.push(".".to_string()); + + // Add parent directory to the items list if it exists + let parent_folder = current_folder.parent(); + if let Some(_parent_folder) = parent_folder { + self.items.push("..".to_string()); + } + + let mut directories_in_current_folder = vec![]; + let mut files_in_current_folder = vec![]; + + // Read the contents of the current folder + if let Ok(entries) = std::fs::read_dir(current_folder) { + for entry in entries { + if let Ok(entry) = entry { + if let Ok(metadata) = entry.metadata() { + let name = entry.file_name().to_string_lossy().to_string(); + + // Categorize items into directories and files + if metadata.is_dir() { + directories_in_current_folder + .push(self.theme.format_folder_select_item(&name)); + } else { + files_in_current_folder.push(self.theme.format_file_select_item(&name)); + } + } + } + } + } else { + panic!("Failed to read current folder"); + } + + // Sort the items + directories_in_current_folder.sort(); + // Places the folders above files + self.items.extend(directories_in_current_folder); + + if self.file { + files_in_current_folder.sort(); + self.items.extend(files_in_current_folder); + } + + self + } + + /// Indicates whether to report the selected value after interaction. + /// + /// The default is to report the selection. + 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 or 'Enter' and the index of selected item will be returned. + /// The dialog is rendered on stderr. + /// Result contains `index` if user selected one of items using '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 or 'Enter' and the index of selected item will be returned. + /// The dialog is rendered on stderr. + /// Result contains `Some(index)` if user selected one of items using 'Enter' or `None` if user cancelled with 'Esc' or 'q'. + /// + /// ## Example + /// + ///```rust,no_run + /// use dialoguer::Select; + /// + /// fn main() { + /// let items = vec!["foo", "bar", "baz"]; + /// + /// let selection = Select::new() + /// .with_prompt("What do you choose?") + /// .items(&items) + /// .interact_opt() + /// .unwrap(); + /// + /// match selection { + /// Some(index) => println!("You chose: {}", items[index]), + /// 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) + } + + /// Like `interact` but allows a specific terminal to be set. + 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()); + } + + self = self.process_folder(); + + if self.items.is_empty() { + // this should never happen and could be removed + return Err(io::Error::new( + io::ErrorKind::Other, + "Empty list of items given to `Select`", + ))?; + } + + let mut paging = Paging::new(term, self.items.len(), self.max_length); + let mut render = TermThemeRenderer::new(term, self.theme); + let mut sel = self.default; + + let mut size_vec = Vec::new(); + + for items in self + .items + .iter() + .flat_map(|i| i.split('\n')) + .collect::>() + { + let size = &items.len(); + size_vec.push(*size); + } + + term.hide_cursor()?; + paging.update_page(sel); + + loop { + if let Some(ref prompt) = self.prompt { + paging.render_prompt(|paging_info| render.select_prompt(prompt, paging_info))?; + } + render.folder_select_path(&format!("Current folder: {}", self.current_folder))?; //TODO: parametrize message + + for (idx, item) in self + .items + .iter() + .enumerate() + .skip(paging.current_page * paging.capacity) + .take(paging.capacity) + { + render.select_prompt_item(item, 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::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::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::Enter | Key::Char(' ') if sel != !0 => { + if self.items[sel] == "." { + if self.clear { + render.clear()?; + } + + if let Some(ref prompt) = self.prompt { + if self.report { + render.select_prompt_selection(prompt, &self.items[sel])?; + } + } + + term.show_cursor()?; + term.flush()?; + + return Ok(Some(self.current_folder)); + } else if self.items[sel] == ".." { + let p = std::path::PathBuf::from(&self.current_folder) + .parent() + .unwrap() + .to_string_lossy() + .to_string(); + self.current_folder = p; + self = self.process_folder(); + } else { + let selection = match self.items[sel].find(' ') { + Some(pos) => &self.items[sel][pos + 1..], + None => &self.items[sel], + }; + let mut p = std::path::PathBuf::from(&self.current_folder); + p.push(std::path::Path::new(selection)); + let selected_path_name = p.to_string_lossy().to_string(); + + match std::fs::metadata(p) { + Ok(metadata) if metadata.is_dir() => { + self.current_folder = selected_path_name; + self = self.process_folder(); + } + Ok(metadata) if metadata.is_file() => { + if self.clear { + render.clear()?; + } + + if let Some(ref prompt) = self.prompt { + if self.report { + render.select_prompt_selection(prompt, &self.items[sel])?; + } + } + + term.show_cursor()?; + term.flush()?; + + return Ok(Some(selected_path_name)); + } + _ => { + return Ok(None); // probably return error + } + } + } + // return Ok(Some(sel)); + } + _ => {} + } + + paging.update(sel)?; + if paging.active { + render.clear()?; + } else { + render.clear_preserve_prompt(&size_vec)?; + } + } + } +} + +impl<'a> FolderSelect<'a> { + /// Creates a files select prompt with a specific theme. + /// + /// ## Example + /// + /// ```rust,no_run + /// use dialoguer::{theme::ColorfulTheme, Select}; + /// + /// fn main() { + /// let selection = FolderSelect::with_theme(&ColorfulTheme::default()) + /// .with_prompt("Select some file from /tmp") + /// .folder("/tmp") + /// .file(true) + /// .interact() + /// .unwrap(); + /// } + /// ``` + pub fn with_theme(theme: &'a dyn Theme) -> Self { + Self { + default: !0, + items: vec![], + prompt: None, + report: false, + clear: true, + file: false, + max_length: None, + theme, + current_folder: ".".to_string(), + } + } +} diff --git a/src/prompts/mod.rs b/src/prompts/mod.rs index 1c13185..8948573 100644 --- a/src/prompts/mod.rs +++ b/src/prompts/mod.rs @@ -11,3 +11,6 @@ pub mod fuzzy_select; #[cfg(feature = "password")] pub mod password; + +#[cfg(feature = "folder-select")] +pub mod folder_select; diff --git a/src/theme/colorful.rs b/src/theme/colorful.rs index 9756950..a3496bc 100644 --- a/src/theme/colorful.rs +++ b/src/theme/colorful.rs @@ -423,4 +423,14 @@ impl Theme for ColorfulTheme { let prompt_suffix = &self.prompt_suffix; write!(f, "{prompt_suffix} {st_head}{st_cursor}{st_tail}",) } + + #[cfg(feature = "folder-select")] + fn format_folder_select_item(&self, text: &str) -> String { + return format!("{} {}", "📁", text); + } + + #[cfg(feature = "folder-select")] + fn format_file_select_item(&self, text: &str) -> String { + return format!("{} {}", "📄", text); + } } diff --git a/src/theme/mod.rs b/src/theme/mod.rs index d22001c..e965e8b 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -262,4 +262,14 @@ pub trait Theme { let (st_head, st_tail) = search_term.split_at(bytes_pos); write!(f, "{st_head}|{st_tail}") } + + #[cfg(feature = "folder-select")] + fn format_folder_select_item(&self, text: &str) -> String { + return format!("{} {}", "d ", text); + } + + #[cfg(feature = "folder-select")] + fn format_file_select_item(&self, text: &str) -> String { + return format!("{} {}", "f ", text); + } } diff --git a/src/theme/render.rs b/src/theme/render.rs index e6f3add..374b900 100644 --- a/src/theme/render.rs +++ b/src/theme/render.rs @@ -147,6 +147,11 @@ impl<'a> TermThemeRenderer<'a> { }) } + #[cfg(feature = "folder-select")] + pub fn folder_select_path(&mut self, path: &str) -> Result { + self.write_formatted_line(|this, buf| this.theme.format_select_prompt(buf, path)) + } + pub fn select_prompt_selection(&mut self, prompt: &str, sel: &str) -> Result { self.write_formatted_prompt(|this, buf| { this.theme.format_select_prompt_selection(buf, prompt, sel)