├── .gitignore ├── demos ├── text.gif ├── confirm.gif ├── number.gif ├── password.gif ├── select.gif ├── toggle.gif └── multi_select.gif ├── src ├── utils │ ├── mod.rs │ ├── num_like.rs │ ├── key_listener.rs │ ├── renderer.rs │ └── theme.rs ├── prompts │ ├── mod.rs │ ├── toggle.rs │ ├── confirm.rs │ ├── password.rs │ ├── text.rs │ ├── number.rs │ ├── multi_select.rs │ └── select.rs └── lib.rs ├── examples ├── confirm.rs ├── text.rs ├── toggle.rs ├── select.rs ├── password.rs ├── number.rs └── multi_select.rs ├── Cargo.toml ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | -------------------------------------------------------------------------------- /demos/text.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axelvc/asky/HEAD/demos/text.gif -------------------------------------------------------------------------------- /demos/confirm.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axelvc/asky/HEAD/demos/confirm.gif -------------------------------------------------------------------------------- /demos/number.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axelvc/asky/HEAD/demos/number.gif -------------------------------------------------------------------------------- /demos/password.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axelvc/asky/HEAD/demos/password.gif -------------------------------------------------------------------------------- /demos/select.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axelvc/asky/HEAD/demos/select.gif -------------------------------------------------------------------------------- /demos/toggle.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axelvc/asky/HEAD/demos/toggle.gif -------------------------------------------------------------------------------- /demos/multi_select.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/axelvc/asky/HEAD/demos/multi_select.gif -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod key_listener; 2 | pub mod num_like; 3 | pub mod renderer; 4 | pub mod theme; 5 | -------------------------------------------------------------------------------- /src/prompts/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod confirm; 2 | pub mod multi_select; 3 | pub mod number; 4 | pub mod password; 5 | pub mod select; 6 | pub mod text; 7 | pub mod toggle; 8 | -------------------------------------------------------------------------------- /examples/confirm.rs: -------------------------------------------------------------------------------- 1 | use asky::Confirm; 2 | 3 | fn main() -> std::io::Result<()> { 4 | if Confirm::new("Do you like coffe?").prompt()? { 5 | println!("Great, me too!"); 6 | } 7 | 8 | // ... 9 | 10 | Ok(()) 11 | } -------------------------------------------------------------------------------- /examples/text.rs: -------------------------------------------------------------------------------- 1 | use asky::Text; 2 | 3 | fn main() -> std::io::Result<()> { 4 | let color = Text::new("What's your favorite color?").prompt()?; 5 | println!("{color} is a beautiful color"); 6 | 7 | // ... 8 | 9 | Ok(()) 10 | } -------------------------------------------------------------------------------- /examples/toggle.rs: -------------------------------------------------------------------------------- 1 | use asky::Toggle; 2 | 3 | fn main() -> std::io::Result<()> { 4 | let tabs = Toggle::new("Which is better?", ["Tabs", "Spaces"]).prompt()?; 5 | println!("Great choice"); 6 | 7 | // ... 8 | 9 | Ok(()) 10 | } -------------------------------------------------------------------------------- /examples/select.rs: -------------------------------------------------------------------------------- 1 | use asky::Select; 2 | 3 | fn main() -> std::io::Result<()> { 4 | let choice = Select::new("Choose number", 1..=30).prompt()?; 5 | println!("{choice}, Interesting choice"); 6 | 7 | // ... 8 | 9 | Ok(()) 10 | } 11 | -------------------------------------------------------------------------------- /examples/password.rs: -------------------------------------------------------------------------------- 1 | use asky::Password; 2 | 3 | fn main() -> std::io::Result<()> { 4 | let password = Password::new("What's your IG password?").prompt()?; 5 | 6 | if password.len() >= 1 { 7 | println!("Ultra secure!"); 8 | } 9 | 10 | // ... 11 | 12 | Ok(()) 13 | } -------------------------------------------------------------------------------- /examples/number.rs: -------------------------------------------------------------------------------- 1 | use asky::Number; 2 | 3 | fn main() -> std::io::Result<()> { 4 | 5 | if let Ok(age) = Number::::new("How old are you?").prompt()? { 6 | if age <= 60 { 7 | println!("Pretty young"); 8 | } 9 | } 10 | 11 | // ... 12 | 13 | Ok(()) 14 | } 15 | -------------------------------------------------------------------------------- /examples/multi_select.rs: -------------------------------------------------------------------------------- 1 | use asky::MultiSelect; 2 | 3 | fn main() -> std::io::Result<()> { 4 | let opts = ["Dog", "Cat", "Fish", "Bird", "Other"]; 5 | let choices = MultiSelect::new("What kind of pets do you have?", opts).prompt()?; 6 | 7 | if choices.len() > 2 { 8 | println!("So you love pets"); 9 | } 10 | 11 | // ... 12 | 13 | Ok(()) 14 | } 15 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "asky" 3 | version = "0.1.1" 4 | edition = "2021" 5 | license = "MIT" 6 | description = "Libray to create good looking prompts in the terminal" 7 | categories = ["command-line-interface", "command-line-utilities"] 8 | keywords = ["ask", "cli", "prompt", "question", "readline"] 9 | repository = "https://github.com/axelvc/asky/" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [dependencies] 14 | colored = "2.0.0" 15 | crossterm = "0.26.0" 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Axel Vasquez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/num_like.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Display, str::FromStr}; 2 | 3 | /// A utility trait to allow only numbers in [`Number`] prompt. 4 | /// Also allows to custom handle they based on the type. 5 | /// 6 | /// [`Number`]: crate::Number 7 | pub trait NumLike: Default + Display + FromStr { 8 | /// Check if it is a floating point number. 9 | fn is_float() -> bool { 10 | false 11 | } 12 | 13 | /// Check if it is a signed number. 14 | fn is_signed() -> bool { 15 | false 16 | } 17 | } 18 | 19 | impl NumLike for u8 {} 20 | impl NumLike for u16 {} 21 | impl NumLike for u32 {} 22 | impl NumLike for u64 {} 23 | impl NumLike for u128 {} 24 | impl NumLike for usize {} 25 | 26 | impl NumLike for i8 { 27 | fn is_signed() -> bool { 28 | true 29 | } 30 | } 31 | 32 | impl NumLike for i16 { 33 | fn is_signed() -> bool { 34 | true 35 | } 36 | } 37 | 38 | impl NumLike for i32 { 39 | fn is_signed() -> bool { 40 | true 41 | } 42 | } 43 | 44 | impl NumLike for i64 { 45 | fn is_signed() -> bool { 46 | true 47 | } 48 | } 49 | 50 | impl NumLike for i128 { 51 | fn is_signed() -> bool { 52 | true 53 | } 54 | } 55 | 56 | impl NumLike for isize { 57 | fn is_signed() -> bool { 58 | true 59 | } 60 | } 61 | 62 | impl NumLike for f32 { 63 | fn is_signed() -> bool { 64 | true 65 | } 66 | 67 | fn is_float() -> bool { 68 | true 69 | } 70 | } 71 | 72 | impl NumLike for f64 { 73 | fn is_signed() -> bool { 74 | true 75 | } 76 | 77 | fn is_float() -> bool { 78 | true 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/utils/key_listener.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use crossterm::{ 4 | event::{read, Event, KeyCode, KeyEvent, KeyModifiers}, 5 | terminal, 6 | }; 7 | 8 | use super::renderer::{Printable, Renderer}; 9 | 10 | /// Trait used for the prompts to handle key events 11 | pub trait Typeable { 12 | /// Returns `true` if it should end to listen for more key events 13 | fn handle_key(&mut self, key: KeyEvent) -> bool; 14 | } 15 | 16 | /// Helper function to listen for key events and draw the prompt 17 | pub fn listen(prompt: &mut (impl Printable + Typeable), hide_cursor: bool) -> io::Result<()> { 18 | let mut renderer = Renderer::new(); 19 | 20 | prompt.draw(&mut renderer)?; 21 | 22 | if hide_cursor { 23 | renderer.hide_cursor()?; 24 | } 25 | 26 | renderer.update_draw_time(); 27 | 28 | let mut submit = false; 29 | 30 | while !submit { 31 | // raw mode to listen each key 32 | terminal::enable_raw_mode()?; 33 | let key = read()?; 34 | terminal::disable_raw_mode()?; 35 | 36 | if let Event::Key(key) = key { 37 | handle_abort(key, &mut renderer); 38 | submit = prompt.handle_key(key); 39 | prompt.draw(&mut renderer)?; 40 | } 41 | } 42 | 43 | renderer.update_draw_time(); 44 | 45 | if hide_cursor { 46 | renderer.show_cursor()?; 47 | } 48 | 49 | prompt.draw(&mut renderer) 50 | } 51 | 52 | fn handle_abort(ev: KeyEvent, renderer: &mut Renderer) { 53 | let is_abort = matches!( 54 | ev, 55 | KeyEvent { 56 | code: KeyCode::Esc, 57 | .. 58 | } | KeyEvent { 59 | code: KeyCode::Char('c' | 'd'), 60 | modifiers: KeyModifiers::CONTROL, 61 | .. 62 | } 63 | ); 64 | 65 | if is_abort { 66 | renderer.show_cursor().ok(); 67 | std::process::exit(1) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/utils/renderer.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, Write}; 2 | 3 | use crossterm::{cursor, execute, queue, style::Print, terminal}; 4 | 5 | pub trait Printable { 6 | fn draw(&self, renderer: &mut Renderer) -> io::Result<()>; 7 | } 8 | 9 | /// Enum that indicates the current draw time to format closures. 10 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 11 | pub enum DrawTime { 12 | /// First time that a prompt is displayed. 13 | #[default] 14 | First, 15 | /// The prompt state has been updated. 16 | Update, 17 | /// The last time that a prompt is displayed. 18 | Last, 19 | } 20 | 21 | pub struct Renderer { 22 | pub draw_time: DrawTime, 23 | out: io::Stdout, 24 | } 25 | 26 | impl Renderer { 27 | pub fn new() -> Self { 28 | Renderer { 29 | draw_time: DrawTime::First, 30 | out: io::stdout(), 31 | } 32 | } 33 | 34 | pub fn update_draw_time(&mut self) { 35 | self.draw_time = match self.draw_time { 36 | DrawTime::First => DrawTime::Update, 37 | _ => DrawTime::Last, 38 | } 39 | } 40 | 41 | pub fn print(&mut self, mut text: String) -> io::Result<()> { 42 | if self.draw_time != DrawTime::First { 43 | queue!( 44 | self.out, 45 | cursor::RestorePosition, 46 | terminal::Clear(terminal::ClearType::FromCursorDown), 47 | )?; 48 | } 49 | 50 | if !text.ends_with('\n') { 51 | text.push('\n') 52 | } 53 | 54 | queue!(self.out, Print(&text))?; 55 | 56 | // Saved position is updated each draw because the text lines could be different 57 | // between draws. The last draw is ignored to always set the cursor at the end 58 | // 59 | // The position is saved this way to ensure the correct position when the cursor is at 60 | // the bottom of the terminal. Otherwise, the saved position will be the last row 61 | // and when trying to restore, the next draw will be below the last row. 62 | if self.draw_time != DrawTime::Last { 63 | let (col, row) = cursor::position()?; 64 | let text_lines = text.lines().count() as u16; 65 | 66 | queue!( 67 | self.out, 68 | cursor::MoveToPreviousLine(text_lines), 69 | cursor::SavePosition, 70 | cursor::MoveTo(col, row) 71 | )?; 72 | } 73 | 74 | self.out.flush() 75 | } 76 | 77 | /// Utility function for line input 78 | /// Set initial position based on the position after drawing 79 | pub fn set_cursor(&mut self, [x, y]: [usize; 2]) -> io::Result<()> { 80 | if self.draw_time == DrawTime::Last { 81 | return Ok(()); 82 | } 83 | 84 | queue!(self.out, cursor::RestorePosition)?; 85 | 86 | if y > 0 { 87 | queue!(self.out, cursor::MoveDown(y as u16))?; 88 | } 89 | 90 | if x > 0 { 91 | queue!(self.out, cursor::MoveRight(x as u16))?; 92 | } 93 | 94 | self.out.flush() 95 | } 96 | 97 | pub fn hide_cursor(&mut self) -> io::Result<()> { 98 | execute!(self.out, cursor::Hide) 99 | } 100 | 101 | pub fn show_cursor(&mut self) -> io::Result<()> { 102 | execute!(self.out, cursor::Show) 103 | } 104 | } 105 | 106 | impl Default for Renderer { 107 | fn default() -> Self { 108 | Self::new() 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Good looking prompts for the terminal 2 | //! 3 | //! # Available prompts 4 | //! 5 | //! - [`Confirm`] - Ask yes/no questions. 6 | //! - [`Toggle`] - Choose between two options. 7 | //! - [`Text`] - One-line user input. 8 | //! - [`Number`] - One-line user input of numbers. 9 | //! - [`Password`] - One-line user input as password. 10 | //! - [`Select`] - Select an item from a list. 11 | //! - [`MultiSelect`] - Select multiple items from a list. 12 | //! 13 | //! # Simple Example 14 | //! 15 | //! ```rust, no_run 16 | //! use asky::{Confirm, Text}; 17 | //! 18 | //! fn main() -> std::io::Result<()> { 19 | //! let name = Text::new("Hi. What's your name?").prompt()?; 20 | //! 21 | //! if Confirm::new("Do you like coffee?").prompt()? { 22 | //! println!("Great! Me too"); 23 | //! } else { 24 | //! println!("Hmm... Interesting"); 25 | //! } 26 | //! 27 | //! // ... 28 | //! 29 | //! Ok(()) 30 | //! } 31 | //! ``` 32 | //! 33 | //! # Customization 34 | //! 35 | //! If you'd like to use this crate but don't want the default styles or just want to customize as you like, 36 | //! all the prompts allow setting a custom formatter using `format()` method. 37 | //! 38 | //! The formatter receives a prompt state reference and a [`DrawTime`], 39 | //! and returns the string to display in the terminal. 40 | //! 41 | //! > Note: When using a custom formatter, you are responsible for the presentation of the prompt, 42 | //! > so you must handle the colors, icons, etc. by yourself. 43 | //! 44 | //! #### Example 45 | //! 46 | //! ```rust, no_run 47 | //! # use asky::Confirm; 48 | //! # fn main() -> std::io::Result<()> { 49 | //! Confirm::new("Do you like Rust?") 50 | //! .format(|prompt, _draw_time| { 51 | //! let state = if prompt.active { "Y/n" } else { "y/N" }; 52 | //! format!("{} {}\n", prompt.message, state) 53 | //! }) 54 | //! .prompt(); 55 | //! # Ok(()) 56 | //! # } 57 | //! ``` 58 | //! 59 | //! This will prints 60 | //! 61 | //! ```bash 62 | //! Do you like Rust? y/N 63 | //! ``` 64 | //! 65 | //! ## Cursor Position 66 | //! 67 | //! Almost all the prompts just need a custom string, but some prompts like [`Text`] also requires an array of `[x, y]` 68 | //! position for the cursor, due to these prompts also depends on the cursor position in the process. 69 | //! 70 | //! #### Example 71 | //! 72 | //! ```rust, no_run 73 | //! # use asky::Text; 74 | //! # fn main() -> std::io::Result<()> { 75 | //! Text::new("What is your name") 76 | //! .format(|prompt, _draw_time| { 77 | //! let cursor_col = prompt.input.col; 78 | //! let prefix = "> "; 79 | //! 80 | //! let x = (prefix.len() + cursor_col); 81 | //! let y = 1; 82 | //! 83 | //! ( 84 | //! format!("{}\n{} {}", prompt.message, prefix, prompt.input.value), 85 | //! [x, y], 86 | //! ) 87 | //! }) 88 | //! .prompt(); 89 | //! # Ok(()) 90 | //! # } 91 | //! 92 | //! ``` 93 | //! 94 | //! This will prints 95 | //! 96 | //! ```bash 97 | //! What is your name? 98 | //! > | 99 | //! ``` 100 | //! 101 | //! Where `|` is the cursor position. 102 | #![deny(missing_docs)] 103 | 104 | mod prompts; 105 | mod utils; 106 | 107 | pub use prompts::confirm::Confirm; 108 | pub use prompts::multi_select::MultiSelect; 109 | pub use prompts::number::Number; 110 | pub use prompts::password::Password; 111 | pub use prompts::select::Select; 112 | pub use prompts::text::Text; 113 | pub use prompts::toggle::Toggle; 114 | 115 | pub use prompts::select::{SelectInput, SelectOption}; 116 | pub use prompts::text::LineInput; 117 | pub use utils::num_like::NumLike; 118 | pub use utils::renderer::DrawTime; 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Asky 2 | 3 | > Ansi + ask + yes = Asky 4 | 5 | Good looking prompts for the terminal. 6 | 7 | ## Usage 8 | 9 | First of all, this is a library, so you need to add this to your project 10 | 11 | ```bash 12 | cargo add asky 13 | ``` 14 | 15 | Then, you can [see the documentation](https://docs.rs/asky/). 16 | 17 | ## Demos 18 | 19 | ### Confirm 20 | 21 | ![Confirm prompt gif demo](demos/confirm.gif) 22 | 23 |
24 | Code: 25 | 26 | ```rust 27 | use asky::Confirm; 28 | 29 | fn main() -> std::io::Result<()> { 30 | if Confirm::new("Do you like coffe?").prompt()? { 31 | println!("Great, me too!"); 32 | } 33 | 34 | // ... 35 | 36 | Ok(()) 37 | } 38 | 39 | ``` 40 | 41 |
42 | 43 | ### Toggle 44 | 45 | ![Toggle prompt gif demo](demos/toggle.gif) 46 | 47 |
48 | Code: 49 | 50 | ```rust 51 | use asky::Toggle; 52 | 53 | fn main() -> std::io::Result<()> { 54 | let tabs = Toggle::new("Which is better?", ["Tabs", "Spaces"]).prompt()?; 55 | println!("Great choice"); 56 | 57 | // ... 58 | 59 | Ok(()) 60 | } 61 | ``` 62 | 63 |
64 | 65 | ### Text 66 | 67 | ![Text prompt gif demo](demos/text.gif) 68 | 69 |
70 | Code: 71 | 72 | ```rust 73 | use asky::Text; 74 | 75 | fn main() -> std::io::Result<()> { 76 | let color = Text::new("What's your favorite color?").prompt()?; 77 | println!("{color} is a beautiful color"); 78 | 79 | // ... 80 | 81 | Ok(()) 82 | } 83 | ``` 84 | 85 |
86 | 87 | ### Number 88 | 89 | ![Number prompt gif demo](demos/number.gif) 90 | 91 |
92 | Code: 93 | 94 | ```rust 95 | use asky::Number; 96 | 97 | fn main() -> std::io::Result<()> { 98 | if let Ok(age) = Number::::new("How old are you?").prompt()? { 99 | if age <= 60 { 100 | println!("Pretty young"); 101 | } 102 | } 103 | 104 | // ... 105 | 106 | Ok(()) 107 | } 108 | ``` 109 | 110 |
111 | 112 | ### Password 113 | 114 | ![Password prompt gif demo](demos/password.gif) 115 | 116 |
117 | Code: 118 | 119 | ```rust 120 | use asky::Password; 121 | 122 | fn main() -> std::io::Result<()> { 123 | let password = Password::new("What's your IG password?").prompt()?; 124 | 125 | if password.len() >= 1 { 126 | println!("Ultra secure!"); 127 | } 128 | 129 | // ... 130 | 131 | Ok(()) 132 | } 133 | ``` 134 | 135 |
136 | 137 | ### Select 138 | 139 | ![Select prompt gif demo](demos/select.gif) 140 | 141 |
142 | Code: 143 | 144 | ```rust 145 | use asky::Select; 146 | 147 | fn main() -> std::io::Result<()> { 148 | let choice = Select::new("Choose number", 1..=30).prompt()?; 149 | println!("{choice}, Interesting choice"); 150 | 151 | // ... 152 | 153 | Ok(()) 154 | } 155 | 156 | ``` 157 | 158 |
159 | 160 | ### MultiSelect 161 | 162 | ![Multi select prompt gif demo](demos/multi_select.gif) 163 | 164 |
165 | Code: 166 | 167 | ```rust 168 | use asky::MultiSelect; 169 | 170 | fn main() -> std::io::Result<()> { 171 | let opts = ["Dog", "Cat", "Fish", "Bird", "Other"]; 172 | let choices = MultiSelect::new("What kind of pets do you have?", opts).prompt()?; 173 | 174 | if choices.len() > 2 { 175 | println!("So you love pets"); 176 | } 177 | 178 | // ... 179 | 180 | Ok(()) 181 | } 182 | 183 | ``` 184 | 185 |
186 | 187 | ## Mentions 188 | 189 | Inspired by: 190 | 191 | - [Prompts](https://www.npmjs.com/package/prompts) - Lightweight, beautiful and user-friendly interactive prompts 192 | - [Astro](https://astro.build/) - All-in-one web framework with a beautiful command line tool 193 | - [Gum](https://github.com/charmbracelet/gum) - A tool for glamorous shell scripts 194 | 195 | Alternatives: 196 | 197 | - [Dialoguer](https://github.com/console-rs/dialoguer) - A command line prompting library. 198 | - [Inquire](https://github.com/mikaelmello/inquire) - A library for building interactive prompts on terminals. 199 | - [Requestty](https://github.com/Lutetium-Vanadium/requestty) - An easy-to-use collection of interactive cli prompts. 200 | 201 | --- 202 | 203 | License: [MIT](LICENSE) 204 | -------------------------------------------------------------------------------- /src/prompts/toggle.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use crossterm::event::{KeyCode, KeyEvent}; 4 | 5 | use crate::utils::{ 6 | key_listener::{self, Typeable}, 7 | renderer::{DrawTime, Printable, Renderer}, 8 | theme, 9 | }; 10 | 11 | type Formatter<'a> = dyn Fn(&Toggle, DrawTime) -> String + 'a; 12 | 13 | /// Prompt to choose between two options. 14 | /// 15 | /// # Key Events 16 | /// 17 | /// | Key | Action | 18 | /// | -------------------- | ---------------------------- | 19 | /// | `Enter`, `Backspace` | Submit current/initial value | 20 | /// | `Left`, `h`, `H` | Focus `false` | 21 | /// | `Right`, `l`, `L` | Focus `true` | 22 | /// 23 | /// # Examples 24 | /// 25 | /// ```no_run 26 | /// use asky::Toggle; 27 | /// 28 | /// # fn main() -> std::io::Result<()> { 29 | /// let os = Toggle::new("What is your favorite OS?", ["Android", "IOS"]).prompt()?; 30 | /// 31 | /// println!("{os} is the best!"); 32 | /// # Ok(()) 33 | /// # } 34 | /// ``` 35 | pub struct Toggle<'a> { 36 | /// Message used to display in the prompt. 37 | pub message: &'a str, 38 | /// Options to display in the prompt. 39 | pub options: [&'a str; 2], 40 | /// Current state of the prompt. 41 | pub active: bool, 42 | formatter: Box>, 43 | } 44 | 45 | impl<'a> Toggle<'a> { 46 | /// Create a new toggle prompt. 47 | pub fn new(message: &'a str, options: [&'a str; 2]) -> Self { 48 | Toggle { 49 | message, 50 | options, 51 | active: false, 52 | formatter: Box::new(theme::fmt_toggle), 53 | } 54 | } 55 | 56 | /// Set whether the prompt should be active at start. 57 | pub fn initial(&mut self, value: bool) -> &mut Self { 58 | self.active = value; 59 | self 60 | } 61 | 62 | /// Set custom closure to format the prompt. 63 | /// 64 | /// See: [`Customization`](index.html#customization). 65 | pub fn format(&mut self, formatter: F) -> &mut Self 66 | where 67 | F: Fn(&Toggle, DrawTime) -> String + 'a, 68 | { 69 | self.formatter = Box::new(formatter); 70 | self 71 | } 72 | 73 | /// Display the prompt and return the user answer. 74 | pub fn prompt(&mut self) -> io::Result { 75 | key_listener::listen(self, true)?; 76 | Ok(String::from(self.get_value())) 77 | } 78 | } 79 | 80 | impl Toggle<'_> { 81 | fn get_value(&self) -> &str { 82 | self.options[self.active as usize] 83 | } 84 | } 85 | 86 | impl Typeable for Toggle<'_> { 87 | fn handle_key(&mut self, key: KeyEvent) -> bool { 88 | let mut submit = false; 89 | 90 | match key.code { 91 | // submit focused/initial option 92 | KeyCode::Enter | KeyCode::Backspace => submit = true, 93 | // update focus option 94 | KeyCode::Left | KeyCode::Char('h' | 'H') => self.active = false, 95 | KeyCode::Right | KeyCode::Char('l' | 'L') => self.active = true, 96 | _ => (), 97 | } 98 | 99 | submit 100 | } 101 | } 102 | 103 | impl Printable for Toggle<'_> { 104 | fn draw(&self, renderer: &mut Renderer) -> io::Result<()> { 105 | let text = (self.formatter)(self, renderer.draw_time); 106 | renderer.print(text) 107 | } 108 | } 109 | 110 | #[cfg(test)] 111 | mod tests { 112 | use super::*; 113 | 114 | #[test] 115 | fn set_initial_value() { 116 | let mut prompt = Toggle::new("", ["foo", "bar"]); 117 | 118 | prompt.initial(false); 119 | assert_eq!(prompt.get_value(), "foo"); 120 | prompt.initial(true); 121 | assert_eq!(prompt.get_value(), "bar"); 122 | } 123 | 124 | #[test] 125 | fn set_custom_formatter() { 126 | let mut prompt = Toggle::new("", ["foo", "bar"]); 127 | let draw_time = DrawTime::First; 128 | const EXPECTED_VALUE: &str = "foo"; 129 | 130 | prompt.format(|_, _| String::from(EXPECTED_VALUE)); 131 | 132 | assert_eq!((prompt.formatter)(&prompt, draw_time), EXPECTED_VALUE); 133 | } 134 | 135 | #[test] 136 | fn sumit_focused() { 137 | let events = [KeyCode::Enter, KeyCode::Backspace]; 138 | 139 | for event in events { 140 | let mut prompt = Toggle::new("", ["foo", "bar"]); 141 | let simulated_key = KeyEvent::from(event); 142 | 143 | let submit = prompt.handle_key(simulated_key); 144 | assert_eq!(prompt.get_value(), "foo"); 145 | assert!(submit); 146 | } 147 | } 148 | 149 | #[test] 150 | fn update_focused() { 151 | let events = [ 152 | (KeyCode::Left, true, "foo"), 153 | (KeyCode::Char('h'), true, "foo"), 154 | (KeyCode::Char('H'), true, "foo"), 155 | (KeyCode::Right, false, "bar"), 156 | (KeyCode::Char('l'), false, "bar"), 157 | (KeyCode::Char('L'), false, "bar"), 158 | ]; 159 | 160 | for (key, initial, expected) in events { 161 | let mut prompt = Toggle::new("", ["foo", "bar"]); 162 | let simulated_key = KeyEvent::from(key); 163 | 164 | prompt.initial(initial); 165 | let submit = prompt.handle_key(simulated_key); 166 | 167 | assert_eq!(prompt.get_value(), expected); 168 | assert!(!submit); 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/prompts/confirm.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use crossterm::event::{KeyCode, KeyEvent}; 4 | 5 | use crate::utils::{ 6 | key_listener::{self, Typeable}, 7 | renderer::{DrawTime, Printable, Renderer}, 8 | theme, 9 | }; 10 | 11 | type Formatter<'a> = dyn Fn(&Confirm, DrawTime) -> String + 'a; 12 | 13 | /// Prompt to ask yes/no questions. 14 | /// 15 | /// # Key Events 16 | /// 17 | /// | Key | Action | 18 | /// | -------------------- | ---------------------------- | 19 | /// | `Enter`, `Backspace` | Submit current/initial value | 20 | /// | `y`, `Y` | Submit `true` | 21 | /// | `n`, `N` | Submit `false` | 22 | /// | `Left`, `h`, `H` | Focus `false` | 23 | /// | `Right`, `l`, `L` | Focus `true` | 24 | /// 25 | /// # Examples 26 | /// 27 | /// ```no_run 28 | /// use asky::Confirm; 29 | /// 30 | /// # fn main() -> std::io::Result<()> { 31 | /// if Confirm::new("Do you like the pizza?").prompt()? { 32 | /// println!("Great!"); 33 | /// } else { 34 | /// println!("Interesting!"); 35 | /// } 36 | /// # Ok(()) 37 | /// # } 38 | /// ``` 39 | pub struct Confirm<'a> { 40 | /// Message used to display in the prompt. 41 | pub message: &'a str, 42 | /// Current state of the prompt. 43 | pub active: bool, 44 | formatter: Box>, 45 | } 46 | 47 | impl<'a> Confirm<'a> { 48 | /// Create a new confirm prompt. 49 | pub fn new(message: &'a str) -> Self { 50 | Confirm { 51 | message, 52 | active: false, 53 | formatter: Box::new(theme::fmt_confirm), 54 | } 55 | } 56 | 57 | /// Set whether the prompt should be active at start. 58 | pub fn initial(&mut self, active: bool) -> &mut Self { 59 | self.active = active; 60 | self 61 | } 62 | 63 | /// Set custom closure to format the prompt. 64 | /// 65 | /// See: [`Customization`](index.html#customization). 66 | pub fn format(&mut self, formatter: F) -> &mut Self 67 | where 68 | F: Fn(&Confirm, DrawTime) -> String + 'a, 69 | { 70 | self.formatter = Box::new(formatter); 71 | self 72 | } 73 | 74 | /// Display the prompt and return the user answer. 75 | pub fn prompt(&mut self) -> io::Result { 76 | key_listener::listen(self, true)?; 77 | Ok(self.active) 78 | } 79 | } 80 | 81 | impl Confirm<'_> { 82 | fn update_and_submit(&mut self, active: bool) -> bool { 83 | self.active = active; 84 | true 85 | } 86 | } 87 | 88 | impl Typeable for Confirm<'_> { 89 | fn handle_key(&mut self, key: KeyEvent) -> bool { 90 | let mut submit = false; 91 | 92 | match key.code { 93 | // update value 94 | KeyCode::Left | KeyCode::Char('h' | 'H') => self.active = false, 95 | KeyCode::Right | KeyCode::Char('l' | 'L') => self.active = true, 96 | // update value and submit 97 | KeyCode::Char('y' | 'Y') => submit = self.update_and_submit(true), 98 | KeyCode::Char('n' | 'N') => submit = self.update_and_submit(false), 99 | // submit current/initial value 100 | KeyCode::Enter | KeyCode::Backspace => submit = true, 101 | _ => (), 102 | } 103 | 104 | submit 105 | } 106 | } 107 | 108 | impl Printable for Confirm<'_> { 109 | fn draw(&self, renderer: &mut Renderer) -> io::Result<()> { 110 | let text = (self.formatter)(self, renderer.draw_time); 111 | renderer.print(text) 112 | } 113 | } 114 | 115 | #[cfg(test)] 116 | mod tests { 117 | use super::*; 118 | 119 | #[test] 120 | fn set_initial_value() { 121 | let mut prompt = Confirm::new(""); 122 | 123 | prompt.initial(false); 124 | assert!(!prompt.active); 125 | prompt.initial(true); 126 | assert!(prompt.active); 127 | } 128 | 129 | #[test] 130 | fn set_custom_formatter() { 131 | let mut prompt: Confirm = Confirm::new(""); 132 | let draw_time = DrawTime::First; 133 | const EXPECTED_VALUE: &str = "foo"; 134 | 135 | prompt.format(|_, _| String::from(EXPECTED_VALUE)); 136 | 137 | assert_eq!((prompt.formatter)(&prompt, draw_time), EXPECTED_VALUE); 138 | } 139 | 140 | #[test] 141 | fn update_and_submit() { 142 | let events = [('y', true), ('Y', true), ('n', false), ('N', false)]; 143 | 144 | for (char, expected) in events { 145 | let mut prompt = Confirm::new(""); 146 | let simulated_key = KeyEvent::from(KeyCode::Char(char)); 147 | 148 | prompt.initial(!expected); 149 | let submit = prompt.handle_key(simulated_key); 150 | 151 | assert_eq!(prompt.active, expected); 152 | assert!(submit); 153 | } 154 | } 155 | 156 | #[test] 157 | fn submit_focused() { 158 | let events = [KeyCode::Enter, KeyCode::Backspace]; 159 | 160 | for event in events { 161 | let mut prompt = Confirm::new(""); 162 | let simulated_key = KeyEvent::from(event); 163 | 164 | let submit = prompt.handle_key(simulated_key); 165 | assert!(!prompt.active); 166 | assert!(submit); 167 | } 168 | } 169 | 170 | #[test] 171 | fn update_focused() { 172 | let events = [ 173 | (KeyCode::Left, true, false), 174 | (KeyCode::Char('h'), true, false), 175 | (KeyCode::Char('H'), true, false), 176 | (KeyCode::Right, false, true), 177 | (KeyCode::Char('l'), false, true), 178 | (KeyCode::Char('L'), false, true), 179 | ]; 180 | 181 | for (key, initial, expected) in events { 182 | let mut prompt = Confirm::new(""); 183 | let simulated_key = KeyEvent::from(key); 184 | 185 | prompt.initial(initial); 186 | let submit = prompt.handle_key(simulated_key); 187 | 188 | assert_eq!(prompt.active, expected); 189 | assert!(!submit); 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/prompts/password.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use crossterm::event::{KeyCode, KeyEvent}; 4 | 5 | use crate::utils::{ 6 | key_listener::{self, Typeable}, 7 | renderer::{DrawTime, Printable, Renderer}, 8 | theme, 9 | }; 10 | 11 | use super::text::{Direction, InputValidator, LineInput}; 12 | 13 | type Formatter<'a> = dyn Fn(&Password, DrawTime) -> (String, [usize; 2]) + 'a; 14 | 15 | /// Prompt to get one-line user input as password. 16 | /// 17 | /// Similar to [`Text`] prompt, but replace input characters with `*`. 18 | /// Also allow to hide user input completely. 19 | /// 20 | /// # Key Events 21 | /// 22 | /// | Key | Action | 23 | /// | ----------- | ---------------------------- | 24 | /// | `Enter` | Submit current/initial value | 25 | /// | `Backspace` | Delete previous character | 26 | /// | `Delete` | Delete current character | 27 | /// | `Left` | Move cursor left | 28 | /// | `Right` | Move cursor right | 29 | /// 30 | /// # Examples 31 | /// 32 | /// ```no_run 33 | /// use asky::Password; 34 | /// 35 | /// # fn main() -> std::io::Result<()> { 36 | /// let password = Password::new("Your IG Password:").prompt()?; 37 | /// # Ok(()) 38 | /// # } 39 | /// ``` 40 | /// [`Text`]: crate::Text 41 | pub struct Password<'a> { 42 | /// Message used to display in the prompt. 43 | pub message: &'a str, 44 | /// Input state for the prompt. 45 | pub input: LineInput, 46 | /// Placeholder to show when the input is empty. 47 | pub placeholder: Option<&'a str>, 48 | /// Default value to submit when the input is empty. 49 | pub default_value: Option<&'a str>, 50 | /// Must hide user input or show `*` characters 51 | pub hidden: bool, 52 | /// State of the validation of the user input. 53 | pub validator_result: Result<(), &'a str>, 54 | validator: Option>>, 55 | formatter: Box>, 56 | } 57 | 58 | impl<'a> Password<'a> { 59 | /// Create a new password prompt. 60 | pub fn new(message: &'a str) -> Self { 61 | Password { 62 | message, 63 | input: LineInput::new(), 64 | placeholder: None, 65 | default_value: None, 66 | hidden: false, 67 | validator: None, 68 | validator_result: Ok(()), 69 | formatter: Box::new(theme::fmt_password), 70 | } 71 | } 72 | 73 | /// Set text to show when the input is empty. 74 | /// 75 | /// This not will not be submitted when the input is empty. 76 | pub fn placeholder(&mut self, value: &'a str) -> &mut Self { 77 | self.placeholder = Some(value); 78 | self 79 | } 80 | 81 | /// Set default value to submit when the input is empty. 82 | pub fn default(&mut self, value: &'a str) -> &mut Self { 83 | self.default_value = Some(value); 84 | self 85 | } 86 | 87 | /// Set initial value, could be deleted by the user. 88 | pub fn initial(&mut self, value: &str) -> &mut Self { 89 | self.input.set_value(value); 90 | self 91 | } 92 | 93 | /// Set whether to hide user input or show `*` characters 94 | pub fn hidden(&mut self, hidden: bool) -> &mut Self { 95 | self.hidden = hidden; 96 | self 97 | } 98 | 99 | /// Set validator to the user input. 100 | pub fn validate(&mut self, validator: F) -> &mut Self 101 | where 102 | F: Fn(&str) -> Result<(), &'a str> + 'a, 103 | { 104 | self.validator = Some(Box::new(validator)); 105 | self 106 | } 107 | 108 | /// Set custom closure to format the prompt. 109 | /// 110 | /// See: [`Customization`](index.html#customization). 111 | pub fn format(&mut self, formatter: F) -> &mut Self 112 | where 113 | F: Fn(&Password, DrawTime) -> (String, [usize; 2]) + 'a, 114 | { 115 | self.formatter = Box::new(formatter); 116 | self 117 | } 118 | 119 | /// Display the prompt and return the user answer. 120 | pub fn prompt(&mut self) -> io::Result { 121 | key_listener::listen(self, false)?; 122 | Ok(self.get_value().to_owned()) 123 | } 124 | } 125 | 126 | impl Password<'_> { 127 | fn get_value(&self) -> &str { 128 | match self.input.value.is_empty() { 129 | true => self.default_value.unwrap_or_default(), 130 | false => &self.input.value, 131 | } 132 | } 133 | 134 | fn validate_to_submit(&mut self) -> bool { 135 | if let Some(validator) = &self.validator { 136 | self.validator_result = validator(self.get_value()); 137 | } 138 | 139 | self.validator_result.is_ok() 140 | } 141 | } 142 | 143 | impl Typeable for Password<'_> { 144 | fn handle_key(&mut self, key: KeyEvent) -> bool { 145 | let mut submit = false; 146 | 147 | match key.code { 148 | // submit 149 | KeyCode::Enter => submit = self.validate_to_submit(), 150 | // type 151 | KeyCode::Char(c) => self.input.insert(c), 152 | // remove delete 153 | KeyCode::Backspace => self.input.backspace(), 154 | KeyCode::Delete => self.input.delete(), 155 | // move cursor 156 | KeyCode::Left => self.input.move_cursor(Direction::Left), 157 | KeyCode::Right => self.input.move_cursor(Direction::Right), 158 | _ => (), 159 | }; 160 | 161 | submit 162 | } 163 | } 164 | 165 | impl Printable for Password<'_> { 166 | fn draw(&self, renderer: &mut Renderer) -> io::Result<()> { 167 | let (text, cursor) = (self.formatter)(self, renderer.draw_time); 168 | renderer.print(text)?; 169 | renderer.set_cursor(cursor) 170 | } 171 | } 172 | 173 | #[cfg(test)] 174 | mod tests { 175 | use super::*; 176 | 177 | #[test] 178 | fn set_placeholder() { 179 | let mut text = Password::new(""); 180 | 181 | assert_eq!(text.placeholder, None); 182 | text.placeholder("foo"); 183 | assert_eq!(text.placeholder, Some("foo")); 184 | } 185 | 186 | #[test] 187 | fn set_default_value() { 188 | let mut text = Password::new(""); 189 | 190 | assert_eq!(text.default_value, None); 191 | text.default("foo"); 192 | assert_eq!(text.default_value, Some("foo")); 193 | } 194 | 195 | #[test] 196 | fn set_initial_value() { 197 | let mut prompt = Password::new(""); 198 | 199 | assert_eq!(prompt.input, LineInput::new()); 200 | 201 | prompt.initial("foo"); 202 | 203 | assert_eq!( 204 | prompt.input, 205 | LineInput { 206 | value: String::from("foo"), 207 | col: 3, 208 | } 209 | ); 210 | } 211 | 212 | #[test] 213 | fn set_custom_formatter() { 214 | let mut prompt: Password = Password::new(""); 215 | let draw_time = DrawTime::First; 216 | const EXPECTED_VALUE: &str = "foo"; 217 | 218 | prompt.format(|_, _| (String::from(EXPECTED_VALUE), [0, 0])); 219 | 220 | assert_eq!( 221 | (prompt.formatter)(&prompt, draw_time), 222 | (String::from(EXPECTED_VALUE), [0, 0]) 223 | ); 224 | } 225 | 226 | #[test] 227 | fn set_hidden_value() { 228 | let mut prompt = Password::new(""); 229 | 230 | assert!(!prompt.hidden); 231 | prompt.hidden(true); 232 | assert!(prompt.hidden) 233 | } 234 | 235 | #[test] 236 | fn update_cursor_position() { 237 | let mut prompt = Password::new(""); 238 | prompt.input.set_value("foo"); 239 | prompt.input.col = 2; 240 | 241 | let keys = [(KeyCode::Left, 1), (KeyCode::Right, 2)]; 242 | 243 | for (key, expected) in keys { 244 | prompt.handle_key(KeyEvent::from(key)); 245 | 246 | assert_eq!(prompt.input.col, expected); 247 | } 248 | } 249 | 250 | #[test] 251 | fn submit_input_value() { 252 | let mut prompt = Password::new(""); 253 | prompt.input.set_value("foo"); 254 | prompt.default("bar"); 255 | 256 | assert_eq!(prompt.get_value(), "foo"); 257 | } 258 | 259 | #[test] 260 | fn submit_default_value() { 261 | let mut prompt = Password::new(""); 262 | prompt.input.set_value(""); 263 | prompt.default("bar"); 264 | 265 | assert_eq!(prompt.get_value(), "bar"); 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /src/utils/theme.rs: -------------------------------------------------------------------------------- 1 | use colored::Colorize; 2 | 3 | use crate::prompts::{ 4 | confirm::Confirm, 5 | multi_select::MultiSelect, 6 | number::Number, 7 | password::Password, 8 | select::{Select, SelectInput, SelectOption}, 9 | text::Text, 10 | toggle::Toggle, 11 | }; 12 | 13 | use super::{num_like::NumLike, renderer::DrawTime}; 14 | 15 | pub fn fmt_confirm(prompt: &Confirm, draw_time: DrawTime) -> String { 16 | let options = ["No", "Yes"]; 17 | 18 | if draw_time == DrawTime::Last { 19 | return fmt_last_message(prompt.message, options[prompt.active as usize]); 20 | } 21 | 22 | [ 23 | fmt_message(prompt.message), 24 | fmt_toggle_options(options, prompt.active), 25 | ] 26 | .join("\n") 27 | } 28 | 29 | pub fn fmt_toggle(prompt: &Toggle, draw_time: DrawTime) -> String { 30 | if draw_time == DrawTime::Last { 31 | return fmt_last_message(prompt.message, prompt.options[prompt.active as usize]); 32 | } 33 | 34 | [ 35 | fmt_message(prompt.message), 36 | fmt_toggle_options(prompt.options, prompt.active), 37 | ] 38 | .join("\n") 39 | } 40 | 41 | pub fn fmt_select(prompt: &Select, draw_time: DrawTime) -> String { 42 | if draw_time == DrawTime::Last { 43 | return fmt_last_message(prompt.message, &prompt.options[prompt.input.focused].title); 44 | } 45 | 46 | [ 47 | fmt_message(prompt.message), 48 | fmt_select_page_options(&prompt.options, &prompt.input, false), 49 | fmt_select_pagination(prompt.input.get_page(), prompt.input.count_pages()), 50 | ] 51 | .join("\n") 52 | } 53 | 54 | pub fn fmt_multi_select(prompt: &MultiSelect, draw_time: DrawTime) -> String { 55 | if draw_time == DrawTime::Last { 56 | return fmt_last_message( 57 | prompt.message, 58 | &format!( 59 | "[{}]", 60 | prompt 61 | .options 62 | .iter() 63 | .filter(|opt| opt.active) 64 | .map(|opt| opt.title.as_str()) 65 | .collect::>() 66 | .join(", "), 67 | ), 68 | ); 69 | } 70 | 71 | [ 72 | fmt_multi_select_message(prompt.message, prompt.min, prompt.max), 73 | fmt_select_page_options(&prompt.options, &prompt.input, true), 74 | fmt_select_pagination(prompt.input.get_page(), prompt.input.count_pages()), 75 | ] 76 | .join("\n") 77 | } 78 | 79 | pub fn fmt_text(prompt: &Text, draw_time: DrawTime) -> (String, [usize; 2]) { 80 | if draw_time == DrawTime::Last { 81 | return ( 82 | fmt_last_message(prompt.message, &prompt.input.value), 83 | [0, 0], 84 | ); 85 | } 86 | 87 | ( 88 | [ 89 | fmt_line_message(prompt.message, &prompt.default_value), 90 | fmt_line_input( 91 | &prompt.input.value, 92 | &prompt.placeholder, 93 | &prompt.validator_result, 94 | false, 95 | ), 96 | fmt_line_validator(&prompt.validator_result), 97 | ] 98 | .join("\n"), 99 | get_cursor_position(prompt.input.col), 100 | ) 101 | } 102 | 103 | pub fn fmt_password(prompt: &Password, draw_time: DrawTime) -> (String, [usize; 2]) { 104 | if draw_time == DrawTime::Last { 105 | return (fmt_last_message(prompt.message, "…"), [0, 0]); 106 | } 107 | 108 | let text = match prompt.hidden { 109 | true => String::new(), 110 | false => "*".repeat(prompt.input.value.len()), 111 | }; 112 | 113 | let cursor_col = if prompt.hidden { 0 } else { prompt.input.col }; 114 | 115 | ( 116 | [ 117 | fmt_line_message(prompt.message, &prompt.default_value), 118 | fmt_line_input(&text, &prompt.placeholder, &prompt.validator_result, false), 119 | fmt_line_validator(&prompt.validator_result), 120 | ] 121 | .join("\n"), 122 | get_cursor_position(cursor_col), 123 | ) 124 | } 125 | 126 | pub fn fmt_number(prompt: &Number, draw_time: DrawTime) -> (String, [usize; 2]) { 127 | if draw_time == DrawTime::Last { 128 | return ( 129 | fmt_last_message(prompt.message, &prompt.input.value), 130 | [0, 0], 131 | ); 132 | } 133 | 134 | ( 135 | [ 136 | fmt_line_message(prompt.message, &prompt.default_value.as_deref()), 137 | fmt_line_input( 138 | &prompt.input.value, 139 | &prompt.placeholder, 140 | &prompt.validator_result, 141 | true, 142 | ), 143 | fmt_line_validator(&prompt.validator_result), 144 | ] 145 | .join("\n"), 146 | get_cursor_position(prompt.input.col), 147 | ) 148 | } 149 | 150 | // region: general 151 | 152 | fn fmt_message(message: &str) -> String { 153 | format!("{} {}", "▣".blue(), message) 154 | } 155 | 156 | fn fmt_last_message(message: &str, answer: &str) -> String { 157 | format!("{} {} {}", "■".green(), message, answer.purple()) 158 | } 159 | 160 | // endregion: general 161 | 162 | // region: toggle 163 | 164 | fn fmt_toggle_options(options: [&str; 2], active: bool) -> String { 165 | let fmt_option = |opt, active| { 166 | let opt = format!(" {} ", opt); 167 | match active { 168 | true => opt.black().on_blue(), 169 | false => opt.white().on_bright_black(), 170 | } 171 | }; 172 | 173 | format!( 174 | "{} {}", 175 | fmt_option(options[0], !active), 176 | fmt_option(options[1], active) 177 | ) 178 | } 179 | 180 | // endregion: toggle 181 | 182 | // region: line 183 | 184 | fn fmt_line_message(msg: &str, default_value: &Option<&str>) -> String { 185 | let value = match default_value { 186 | Some(value) => format!("Default: {}", value).bright_black(), 187 | None => "".normal(), 188 | }; 189 | 190 | format!("{} {}", fmt_message(msg), value) 191 | } 192 | 193 | fn fmt_line_input( 194 | input: &str, 195 | placeholder: &Option<&str>, 196 | validator_result: &Result<(), &str>, 197 | is_number: bool, 198 | ) -> String { 199 | let prefix = match validator_result { 200 | Ok(_) => "›".blue(), 201 | Err(_) => "›".red(), 202 | }; 203 | 204 | let input = match (input.is_empty(), is_number) { 205 | (true, _) => placeholder.unwrap_or_default().bright_black(), 206 | (false, true) => input.yellow(), 207 | (false, false) => input.normal(), 208 | }; 209 | 210 | format!("{} {}", prefix, input) 211 | } 212 | 213 | fn fmt_line_validator(validator_result: &Result<(), &str>) -> String { 214 | match validator_result { 215 | Ok(_) => String::new(), 216 | Err(e) => format!("{}", e.red()), 217 | } 218 | } 219 | 220 | fn get_cursor_position(cursor_col: usize) -> [usize; 2] { 221 | let x = 2 + cursor_col; 222 | let y = 1; 223 | 224 | [x, y] 225 | } 226 | 227 | // endregion: line 228 | 229 | // region: select 230 | 231 | fn fmt_multi_select_message(msg: &str, min: Option, max: Option) -> String { 232 | let min_max = match (min, max) { 233 | (None, None) => String::new(), 234 | (None, Some(max)) => format!("Max: {}", max), 235 | (Some(min), None) => format!("Min: {}", min), 236 | (Some(min), Some(max)) => format!("Min: {} · Max: {}", min, max), 237 | } 238 | .bright_black(); 239 | 240 | format!("{} {}", fmt_message(msg), min_max) 241 | } 242 | 243 | fn fmt_select_page_options( 244 | options: &[SelectOption], 245 | input: &SelectInput, 246 | is_multiple: bool, 247 | ) -> String { 248 | let items_per_page = input.items_per_page; 249 | let total = input.total_items; 250 | 251 | let page_len = items_per_page.min(total); 252 | let page_start = input.get_page() * items_per_page; 253 | let page_end = (page_start + page_len).min(total); 254 | let page_focused = input.focused % items_per_page; 255 | 256 | let mut page_options: Vec = options[page_start..page_end] 257 | .iter() 258 | .enumerate() 259 | .map(|(i, option)| fmt_select_option(option, page_focused == i, is_multiple)) 260 | .collect(); 261 | 262 | page_options.resize(page_len, String::new()); 263 | page_options.join("\n") 264 | } 265 | 266 | fn fmt_select_pagination(page: usize, pages: usize) -> String { 267 | if pages == 1 { 268 | return String::new(); 269 | } 270 | 271 | let icon = "•"; 272 | 273 | format!( 274 | "\n {}{}{}", 275 | icon.repeat(page).bright_black(), 276 | icon, 277 | icon.repeat(pages.saturating_sub(page + 1)).bright_black(), 278 | ) 279 | } 280 | 281 | fn fmt_select_option(option: &SelectOption, focused: bool, multiple: bool) -> String { 282 | let prefix = if multiple { 283 | let prefix = match (option.active, focused) { 284 | (true, true) => "◉", 285 | (true, false) => "●", 286 | _ => "○", 287 | }; 288 | 289 | match (focused, option.active, option.disabled) { 290 | (true, _, true) => prefix.red(), 291 | (true, _, false) => prefix.blue(), 292 | (false, true, _) => prefix.normal(), 293 | (false, false, _) => prefix.bright_black(), 294 | } 295 | } else { 296 | match (focused, option.disabled) { 297 | (false, _) => "○".bright_black(), 298 | (true, true) => "○".red(), 299 | (true, false) => "●".blue(), 300 | } 301 | }; 302 | 303 | let title = &option.title; 304 | let title = match (option.disabled, focused) { 305 | (true, _) => title.bright_black().strikethrough(), 306 | (false, true) => title.blue(), 307 | (false, false) => title.normal(), 308 | }; 309 | 310 | let make_description = |s: &str| format!(" · {}", s).bright_black(); 311 | let description = match (focused, option.disabled, option.description) { 312 | (true, true, _) => make_description("(Disabled)"), 313 | (true, false, Some(description)) => make_description(description), 314 | _ => "".normal(), 315 | }; 316 | 317 | format!("{} {} {}", prefix, title, description) 318 | } 319 | 320 | // endregion: select 321 | -------------------------------------------------------------------------------- /src/prompts/text.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use crossterm::event::{KeyCode, KeyEvent}; 4 | 5 | use crate::utils::{ 6 | key_listener::{self, Typeable}, 7 | renderer::{DrawTime, Printable, Renderer}, 8 | theme, 9 | }; 10 | 11 | pub enum Direction { 12 | Left, 13 | Right, 14 | } 15 | 16 | // region: TextInput 17 | 18 | /// State of the user input for read-line text prompts (like [`Text`]). 19 | /// 20 | /// **Note**: This structure is not expected to be created, but it can be consumed when using a custom formatter. 21 | #[derive(Debug, PartialEq, Eq, Default)] 22 | pub struct LineInput { 23 | /// Current value of the input. 24 | pub value: String, 25 | /// Current position of the cursor. 26 | pub col: usize, 27 | } 28 | 29 | impl LineInput { 30 | pub(crate) fn new() -> Self { 31 | LineInput::default() 32 | } 33 | } 34 | 35 | impl LineInput { 36 | pub(crate) fn set_value(&mut self, value: &str) { 37 | self.value = String::from(value); 38 | self.col = value.len(); 39 | } 40 | 41 | pub(crate) fn insert(&mut self, ch: char) { 42 | self.value.insert(self.col, ch); 43 | self.col += 1; 44 | } 45 | 46 | pub(crate) fn backspace(&mut self) { 47 | if !self.value.is_empty() && self.col > 0 { 48 | self.col -= 1; 49 | self.value.remove(self.col); 50 | } 51 | } 52 | 53 | pub(crate) fn delete(&mut self) { 54 | if !self.value.is_empty() && self.col < self.value.len() { 55 | self.value.remove(self.col); 56 | } 57 | } 58 | 59 | pub(crate) fn move_cursor(&mut self, position: Direction) { 60 | self.col = match position { 61 | Direction::Left => self.col.saturating_sub(1), 62 | Direction::Right => (self.col + 1).min(self.value.len()), 63 | } 64 | } 65 | } 66 | 67 | // endregion: TextInput 68 | 69 | pub type InputValidator<'a> = dyn Fn(&str) -> Result<(), &'a str> + 'a; 70 | type Formatter<'a> = dyn Fn(&Text, DrawTime) -> (String, [usize; 2]) + 'a; 71 | 72 | /// Prompt to get one-line user input. 73 | /// 74 | /// # Key Events 75 | /// 76 | /// | Key | Action | 77 | /// | ----------- | ---------------------------- | 78 | /// | `Enter` | Submit current/initial value | 79 | /// | `Backspace` | Delete previous character | 80 | /// | `Delete` | Delete current character | 81 | /// | `Left` | Move cursor left | 82 | /// | `Right` | Move cursor right | 83 | /// 84 | /// # Examples 85 | /// 86 | /// ```no_run 87 | /// use asky::Text; 88 | /// 89 | /// # fn main() -> std::io::Result<()> { 90 | /// let name = Text::new("What is your name?").prompt()?; 91 | /// 92 | /// println!("Hello, {}!", name); 93 | /// 94 | /// # Ok(()) 95 | /// # } 96 | /// ``` 97 | pub struct Text<'a> { 98 | /// Message used to display in the prompt 99 | pub message: &'a str, 100 | /// Input state for the prompt 101 | pub input: LineInput, 102 | /// Placeholder to show when the input is empty 103 | pub placeholder: Option<&'a str>, 104 | /// Default value to submit when the input is empty 105 | pub default_value: Option<&'a str>, 106 | /// State of the validation of the user input 107 | pub validator_result: Result<(), &'a str>, 108 | validator: Option>>, 109 | formatter: Box>, 110 | } 111 | 112 | impl<'a> Text<'a> { 113 | /// Create a new text prompt. 114 | pub fn new(message: &'a str) -> Self { 115 | Text { 116 | message, 117 | input: LineInput::new(), 118 | placeholder: None, 119 | default_value: None, 120 | validator: None, 121 | validator_result: Ok(()), 122 | formatter: Box::new(theme::fmt_text), 123 | } 124 | } 125 | 126 | /// Set text to show when the input is empty. 127 | /// 128 | /// This not will not be submitted when the input is empty. 129 | pub fn placeholder(&mut self, value: &'a str) -> &mut Self { 130 | self.placeholder = Some(value); 131 | self 132 | } 133 | 134 | /// Set default value to submit when the input is empty. 135 | pub fn default(&mut self, value: &'a str) -> &mut Self { 136 | self.default_value = Some(value); 137 | self 138 | } 139 | 140 | /// Set initial value, could be deleted by the user. 141 | pub fn initial(&mut self, value: &str) -> &mut Self { 142 | self.input.set_value(value); 143 | self 144 | } 145 | 146 | /// Set validator to the user input. 147 | pub fn validate(&mut self, validator: F) -> &mut Self 148 | where 149 | F: Fn(&str) -> Result<(), &'a str> + 'a, 150 | { 151 | self.validator = Some(Box::new(validator)); 152 | self 153 | } 154 | 155 | /// Set custom closure to format the prompt. 156 | /// 157 | /// See: [`Customization`](index.html#customization). 158 | pub fn format(&mut self, formatter: F) -> &mut Self 159 | where 160 | F: Fn(&Text, DrawTime) -> (String, [usize; 2]) + 'a, 161 | { 162 | self.formatter = Box::new(formatter); 163 | self 164 | } 165 | 166 | /// Display the prompt and return the user answer. 167 | pub fn prompt(&mut self) -> io::Result { 168 | key_listener::listen(self, false)?; 169 | Ok(self.get_value().to_owned()) 170 | } 171 | } 172 | 173 | impl Text<'_> { 174 | fn get_value(&self) -> &str { 175 | match self.input.value.is_empty() { 176 | true => self.default_value.unwrap_or_default(), 177 | false => &self.input.value, 178 | } 179 | } 180 | 181 | fn validate_to_submit(&mut self) -> bool { 182 | if let Some(validator) = &self.validator { 183 | self.validator_result = validator(self.get_value()); 184 | } 185 | 186 | self.validator_result.is_ok() 187 | } 188 | } 189 | 190 | impl Typeable for Text<'_> { 191 | fn handle_key(&mut self, key: KeyEvent) -> bool { 192 | let mut submit = false; 193 | 194 | match key.code { 195 | // submit 196 | KeyCode::Enter => submit = self.validate_to_submit(), 197 | // type 198 | KeyCode::Char(c) => self.input.insert(c), 199 | // remove delete 200 | KeyCode::Backspace => self.input.backspace(), 201 | KeyCode::Delete => self.input.delete(), 202 | // move cursor 203 | KeyCode::Left => self.input.move_cursor(Direction::Left), 204 | KeyCode::Right => self.input.move_cursor(Direction::Right), 205 | _ => (), 206 | }; 207 | 208 | submit 209 | } 210 | } 211 | 212 | impl Printable for Text<'_> { 213 | fn draw(&self, renderer: &mut Renderer) -> io::Result<()> { 214 | let (text, cursor) = (self.formatter)(self, renderer.draw_time); 215 | renderer.print(text)?; 216 | renderer.set_cursor(cursor) 217 | } 218 | } 219 | 220 | #[cfg(test)] 221 | mod tests { 222 | use super::*; 223 | 224 | #[test] 225 | fn set_placeholder() { 226 | let mut text = Text::new(""); 227 | text.placeholder("foo"); 228 | 229 | assert_eq!(text.placeholder, Some("foo")); 230 | } 231 | 232 | #[test] 233 | fn set_default_value() { 234 | let mut text = Text::new(""); 235 | text.default("foo"); 236 | 237 | assert_eq!(text.default_value, Some("foo")); 238 | } 239 | 240 | #[test] 241 | fn set_initial_value() { 242 | let mut prompt = Text::new(""); 243 | 244 | assert_eq!(prompt.input, LineInput::new()); 245 | 246 | prompt.initial("foo"); 247 | 248 | assert_eq!( 249 | prompt.input, 250 | LineInput { 251 | value: String::from("foo"), 252 | col: 3, 253 | } 254 | ); 255 | } 256 | 257 | #[test] 258 | fn set_custom_formatter() { 259 | let mut prompt: Text = Text::new(""); 260 | let draw_time = DrawTime::First; 261 | const EXPECTED_VALUE: &str = "foo"; 262 | 263 | prompt.format(|_, _| (String::from(EXPECTED_VALUE), [0, 0])); 264 | 265 | assert_eq!( 266 | (prompt.formatter)(&prompt, draw_time), 267 | (String::from(EXPECTED_VALUE), [0, 0]) 268 | ); 269 | } 270 | 271 | #[test] 272 | fn update_value() { 273 | let mut prompt = Text::new(""); 274 | 275 | // simulate typing 276 | let text = "foo"; 277 | 278 | for char in text.chars() { 279 | prompt.handle_key(KeyEvent::from(KeyCode::Char(char))); 280 | } 281 | 282 | assert_eq!(prompt.input.value, "foo"); 283 | assert_eq!(prompt.input.col, 3); 284 | 285 | // removing 286 | let keys = [(KeyCode::Backspace, "fo"), (KeyCode::Delete, "f")]; 287 | prompt.input.col = 2; 288 | 289 | for (key, expected) in keys { 290 | prompt.handle_key(KeyEvent::from(key)); 291 | 292 | assert_eq!(prompt.input.value, expected); 293 | assert_eq!(prompt.input.col, 1); 294 | } 295 | } 296 | 297 | #[test] 298 | fn update_cursor_position() { 299 | let mut prompt = Text::new(""); 300 | prompt.input.set_value("foo"); 301 | prompt.input.col = 2; 302 | 303 | let keys = [(KeyCode::Left, 1), (KeyCode::Right, 2)]; 304 | 305 | for (key, expected) in keys { 306 | prompt.handle_key(KeyEvent::from(key)); 307 | 308 | assert_eq!(prompt.input.col, expected); 309 | } 310 | } 311 | 312 | #[test] 313 | fn validate_input() { 314 | let mut prompt = Text::new(""); 315 | let err_str = "Please enter an response"; 316 | 317 | prompt.validate(|s| if s.is_empty() { Err(err_str) } else { Ok(()) }); 318 | 319 | // invalid value 320 | let mut submit = prompt.handle_key(KeyEvent::from(KeyCode::Enter)); 321 | 322 | assert!(!submit); 323 | assert_eq!(prompt.validator_result, Err(err_str)); 324 | 325 | // valid value 326 | prompt.input.set_value("foo"); 327 | submit = prompt.handle_key(KeyEvent::from(KeyCode::Enter)); 328 | 329 | assert!(submit); 330 | assert_eq!(prompt.validator_result, Ok(())); 331 | } 332 | 333 | #[test] 334 | fn submit_input_value() { 335 | let mut prompt = Text::new(""); 336 | prompt.input.set_value("foo"); 337 | prompt.default("bar"); 338 | 339 | assert_eq!(prompt.get_value(), "foo"); 340 | } 341 | 342 | #[test] 343 | fn submit_default_value() { 344 | let mut prompt = Text::new(""); 345 | prompt.input.set_value(""); 346 | prompt.default("bar"); 347 | 348 | assert_eq!(prompt.get_value(), "bar"); 349 | } 350 | } 351 | -------------------------------------------------------------------------------- /src/prompts/number.rs: -------------------------------------------------------------------------------- 1 | use std::{io, str::FromStr}; 2 | 3 | use crossterm::event::{KeyCode, KeyEvent}; 4 | 5 | use crate::utils::{ 6 | key_listener::{self, Typeable}, 7 | num_like::NumLike, 8 | renderer::{DrawTime, Printable, Renderer}, 9 | theme, 10 | }; 11 | 12 | use super::text::{Direction, LineInput}; 13 | 14 | type InputValidator<'a, T> = 15 | dyn Fn(&str, Result::Err>) -> Result<(), &'a str> + 'a; 16 | type Formatter<'a, T> = dyn Fn(&Number, DrawTime) -> (String, [usize; 2]) + 'a; 17 | 18 | /// Prompt to get one-line user input of numbers. 19 | /// 20 | /// Similar to [`Text`] prompt, but only accept numbers, decimal point [^decimal], and sign symbol [^sign]. 21 | /// 22 | /// # Key Events 23 | /// 24 | /// | Key | Action | 25 | /// | ----------- | ---------------------------- | 26 | /// | `Enter` | Submit current/initial value | 27 | /// | `Backspace` | Delete previous character | 28 | /// | `Delete` | Delete current character | 29 | /// | `Left` | Move cursor left | 30 | /// | `Right` | Move cursor right | 31 | /// | `Backspace` | Delete previous character | 32 | /// | `.` | Add decimal point [^decimal] | 33 | /// | `-`, `+` | Add sign to the input [^sign] | 34 | /// 35 | /// [^decimal]: Only for floating values. 36 | /// 37 | /// [^sign]: Only for signed values and when cursor is at start of the input. 38 | /// 39 | /// # Examples 40 | /// 41 | /// ```no_run 42 | /// use asky::Number; 43 | /// 44 | /// # fn main() -> std::io::Result<()> { 45 | /// let number = Number::::new("How many pets do you have?").prompt()?; 46 | /// # Ok(()) 47 | /// # } 48 | /// ``` 49 | /// [`Text`]: crate::Text 50 | pub struct Number<'a, T: NumLike> { 51 | /// Message used to display in the prompt. 52 | pub message: &'a str, 53 | /// Input state for the prompt. 54 | pub input: LineInput, 55 | /// Placeholder to show when the input is empty. 56 | pub placeholder: Option<&'a str>, 57 | /// Default value to submit when the input is empty. 58 | pub default_value: Option, 59 | /// State of the validation of the user input. 60 | pub validator_result: Result<(), &'a str>, 61 | validator: Option>>, 62 | formatter: Box>, 63 | } 64 | 65 | impl<'a, T: NumLike + 'a> Number<'a, T> { 66 | /// Create a new number prompt. 67 | pub fn new(message: &'a str) -> Self { 68 | Number { 69 | message, 70 | input: LineInput::new(), 71 | placeholder: None, 72 | default_value: None, 73 | validator: None, 74 | validator_result: Ok(()), 75 | formatter: Box::new(theme::fmt_number), 76 | } 77 | } 78 | 79 | /// Set text to show when the input is empty. 80 | /// 81 | /// This not will not be submitted when the input is empty. 82 | pub fn placeholder(&mut self, value: &'a str) -> &mut Self { 83 | self.placeholder = Some(value); 84 | self 85 | } 86 | 87 | /// Set default value to submit when the input is empty. 88 | pub fn default(&mut self, value: T) -> &mut Self { 89 | self.default_value = Some(value.to_string()); 90 | self 91 | } 92 | 93 | /// Set initial value, could be deleted by the user. 94 | pub fn initial(&mut self, value: T) -> &mut Self { 95 | self.input.set_value(&value.to_string()); 96 | self 97 | } 98 | 99 | /// Set validator to the user input. 100 | pub fn validate(&mut self, validator: F) -> &mut Self 101 | where 102 | F: Fn(&str, Result) -> Result<(), &'a str> + 'static, 103 | { 104 | self.validator = Some(Box::new(validator)); 105 | self 106 | } 107 | 108 | /// Set custom closure to format the prompt. 109 | /// 110 | /// See: [`Customization`](index.html#customization). 111 | pub fn format(&mut self, formatter: F) -> &mut Self 112 | where 113 | F: Fn(&Number, DrawTime) -> (String, [usize; 2]) + 'a, 114 | { 115 | self.formatter = Box::new(formatter); 116 | self 117 | } 118 | 119 | /// Display the prompt and return the user answer. 120 | pub fn prompt(&mut self) -> io::Result> { 121 | key_listener::listen(self, false)?; 122 | Ok(self.get_value()) 123 | } 124 | } 125 | 126 | impl Number<'_, T> { 127 | fn get_value(&self) -> Result { 128 | match self.input.value.is_empty() { 129 | true => self.default_value.clone().unwrap_or_default().parse(), 130 | false => self.input.value.parse(), 131 | } 132 | } 133 | 134 | fn insert(&mut self, ch: char) { 135 | let is_valid = match ch { 136 | '-' | '+' => T::is_signed() && self.input.col == 0, 137 | '.' => T::is_float() && !self.input.value.contains('.'), 138 | _ => ch.is_ascii_digit(), 139 | }; 140 | 141 | if is_valid { 142 | self.input.insert(ch) 143 | } 144 | } 145 | 146 | fn validate_to_submit(&mut self) -> bool { 147 | if let Some(validator) = &self.validator { 148 | self.validator_result = validator(&self.input.value, self.get_value()); 149 | } 150 | 151 | self.validator_result.is_ok() 152 | } 153 | } 154 | 155 | impl Typeable for Number<'_, T> { 156 | fn handle_key(&mut self, key: KeyEvent) -> bool { 157 | let mut submit = false; 158 | 159 | match key.code { 160 | // submit 161 | KeyCode::Enter => submit = self.validate_to_submit(), 162 | // type 163 | KeyCode::Char(c) => self.insert(c), 164 | // remove delete 165 | KeyCode::Backspace => self.input.backspace(), 166 | KeyCode::Delete => self.input.delete(), 167 | // move cursor 168 | KeyCode::Left => self.input.move_cursor(Direction::Left), 169 | KeyCode::Right => self.input.move_cursor(Direction::Right), 170 | _ => (), 171 | } 172 | 173 | submit 174 | } 175 | } 176 | 177 | impl Printable for Number<'_, T> { 178 | fn draw(&self, renderer: &mut Renderer) -> io::Result<()> { 179 | let (text, cursor) = (self.formatter)(self, renderer.draw_time); 180 | renderer.print(text)?; 181 | renderer.set_cursor(cursor) 182 | } 183 | } 184 | 185 | impl<'a, T: NumLike + 'a> Default for Number<'a, T> { 186 | fn default() -> Self { 187 | Self::new("") 188 | } 189 | } 190 | 191 | #[cfg(test)] 192 | mod tests { 193 | use super::*; 194 | 195 | #[test] 196 | fn set_placeholder() { 197 | let mut text = Number::::new(""); 198 | 199 | assert_eq!(text.placeholder, None); 200 | text.placeholder("foo"); 201 | assert_eq!(text.placeholder, Some("foo")); 202 | } 203 | 204 | #[test] 205 | fn set_default_value() { 206 | let mut text = Number::::new(""); 207 | 208 | assert_eq!(text.default_value, None); 209 | text.default(10); 210 | assert_eq!(text.default_value, Some(String::from("10"))); 211 | } 212 | 213 | #[test] 214 | fn set_initial_value() { 215 | let mut prompt = Number::::new(""); 216 | 217 | assert_eq!(prompt.input, LineInput::new()); 218 | 219 | prompt.initial(10); 220 | 221 | assert_eq!( 222 | prompt.input, 223 | LineInput { 224 | value: String::from("10"), 225 | col: 2, 226 | } 227 | ); 228 | } 229 | 230 | #[test] 231 | fn set_custom_formatter() { 232 | let mut prompt: Number = Number::new(""); 233 | let draw_time = DrawTime::First; 234 | const EXPECTED_VALUE: &str = "foo"; 235 | 236 | prompt.format(|_, _| (String::from(EXPECTED_VALUE), [0, 0])); 237 | 238 | assert_eq!( 239 | (prompt.formatter)(&prompt, draw_time), 240 | (String::from(EXPECTED_VALUE), [0, 0]) 241 | ); 242 | } 243 | 244 | #[test] 245 | fn update_cursor_position() { 246 | let mut prompt = Number::::new(""); 247 | prompt.input.set_value("foo"); 248 | prompt.input.col = 2; 249 | 250 | let keys = [(KeyCode::Left, 1), (KeyCode::Right, 2)]; 251 | 252 | for (key, expected) in keys { 253 | prompt.handle_key(KeyEvent::from(key)); 254 | 255 | assert_eq!(prompt.input.col, expected); 256 | } 257 | } 258 | 259 | #[test] 260 | fn submit_input_value() { 261 | let mut prompt = Number::::new(""); 262 | prompt.input.set_value(&String::from("10")); 263 | prompt.default(20); 264 | 265 | assert_eq!(prompt.get_value(), Ok(10)); 266 | } 267 | 268 | #[test] 269 | fn submit_default_value() { 270 | let mut prompt = Number::::new(""); 271 | prompt.input.set_value(""); 272 | prompt.default(20); 273 | 274 | assert_eq!(prompt.get_value(), Ok(20)); 275 | } 276 | 277 | #[test] 278 | fn allow_sign_at_the_start() { 279 | let signs = ['-', '+']; 280 | 281 | for c in signs { 282 | let mut prompt = Number::::new(""); 283 | 284 | // must accept only one sign, simulate double press 285 | prompt.handle_key(KeyEvent::from(KeyCode::Char(c))); 286 | prompt.handle_key(KeyEvent::from(KeyCode::Char(c))); 287 | 288 | assert_eq!(prompt.input.value, c.to_string()); 289 | } 290 | 291 | // not allow fo unsigned types 292 | for c in signs { 293 | let mut prompt = Number::::new(""); 294 | 295 | prompt.handle_key(KeyEvent::from(KeyCode::Char(c))); 296 | 297 | assert!(prompt.input.value.is_empty()); 298 | } 299 | } 300 | 301 | #[test] 302 | fn allow_only_digits() { 303 | let mut prompt = Number::::new(""); 304 | 305 | // try to type a character 306 | ('a'..='z').for_each(|c| { 307 | prompt.handle_key(KeyEvent::from(KeyCode::Char(c))); 308 | }); 309 | 310 | // try to type digits 311 | ('0'..='9').for_each(|c| { 312 | prompt.handle_key(KeyEvent::from(KeyCode::Char(c))); 313 | }); 314 | 315 | assert_eq!(prompt.input.value, "0123456789"); 316 | } 317 | 318 | #[test] 319 | fn allow_decimal_in_floats() { 320 | let mut prompt = Number::::new(""); 321 | 322 | "1.".chars().for_each(|c| { 323 | prompt.handle_key(KeyEvent::from(KeyCode::Char(c))); 324 | }); 325 | 326 | assert_eq!(prompt.input.value, "1."); 327 | 328 | // not allow in integers 329 | let mut prompt = Number::::new(""); 330 | 331 | "2.".chars().for_each(|c| { 332 | prompt.handle_key(KeyEvent::from(KeyCode::Char(c))); 333 | }); 334 | 335 | assert_eq!(prompt.input.value, "2"); 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /src/prompts/multi_select.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use crossterm::event::{KeyCode, KeyEvent}; 4 | 5 | use crate::utils::{ 6 | key_listener::{self, Typeable}, 7 | renderer::{DrawTime, Printable, Renderer}, 8 | theme, 9 | }; 10 | 11 | use super::select::{Direction, SelectInput, SelectOption}; 12 | 13 | type Formatter<'a, T> = dyn Fn(&MultiSelect, DrawTime) -> String + 'a; 14 | 15 | /// Prompt to select multiple items from a list. 16 | /// 17 | /// To allow only one item to be selected, it is recommended to use [`Select`] struct instead. 18 | /// # Key Events 19 | /// 20 | /// | Key | Action | 21 | /// | -------------------- | ------------------------------- | 22 | /// | `Enter`, `Backspace` | Submit current/initial value | 23 | /// | `Space` | Toggle selected in focused item | 24 | /// | `Up`, `k`, `K` | Focus next item | 25 | /// | `Down`, `j`, `J` | Focus previous item | 26 | /// | `Left`, `h`, `H` | Focus next page | 27 | /// | `Right`, `l`, `L` | Focus previous page | 28 | /// 29 | /// # Examples 30 | /// 31 | /// ```no_run 32 | /// use asky::MultiSelect; 33 | /// 34 | /// # fn main() -> std::io::Result<()> { 35 | /// let options = ["Horror", "Romance", "Action", "Comedy"]; 36 | /// let answer = MultiSelect::new("What genre do you like?", options).prompt()?; 37 | /// # Ok(()) 38 | /// # } 39 | /// ``` 40 | /// [`Select`]: crate::Select 41 | pub struct MultiSelect<'a, T> { 42 | /// Message used to display in the prompt. 43 | pub message: &'a str, 44 | /// List of options. 45 | pub options: Vec>, 46 | /// Minimum number of items required to be selected. 47 | pub min: Option, 48 | /// Maximum number of items allowed to be selected. 49 | pub max: Option, 50 | /// Input state. 51 | pub input: SelectInput, 52 | selected_count: usize, 53 | formatter: Box>, 54 | } 55 | 56 | impl<'a, T: 'a> MultiSelect<'a, T> { 57 | /// Create a new multi-select prompt. 58 | pub fn new(message: &'a str, iter: I) -> Self 59 | where 60 | I: IntoIterator, 61 | T: ToString, 62 | { 63 | let options = iter.into_iter().map(|o| SelectOption::new(o)).collect(); 64 | Self::new_complex(message, options) 65 | } 66 | 67 | /// Create a new multi-select prompt with custom [`SelectOption`] items. 68 | /// 69 | /// Example: 70 | /// 71 | /// ```no_run 72 | /// use asky::{MultiSelect, SelectOption}; 73 | /// 74 | /// # fn main() -> std::io::Result<()> { 75 | /// let options = vec![ 76 | /// SelectOption::new("Reading"), 77 | /// SelectOption::new("Watching TV"), 78 | /// SelectOption::new("Playing video games"), 79 | /// SelectOption::new("Sleeping"), 80 | /// ]; 81 | /// 82 | /// MultiSelect::new_complex("How do you like to spend your free time?", options).prompt()?; 83 | /// # Ok(()) 84 | /// # } 85 | pub fn new_complex(message: &'a str, options: Vec>) -> Self { 86 | let options_len = options.len(); 87 | 88 | MultiSelect { 89 | message, 90 | options, 91 | min: None, 92 | max: None, 93 | selected_count: 0, 94 | input: SelectInput::new(options_len), 95 | formatter: Box::new(theme::fmt_multi_select), 96 | } 97 | } 98 | 99 | /// Set initial selected indices. 100 | pub fn selected(&mut self, indices: &[usize]) -> &mut Self { 101 | for i in indices { 102 | if let Some(option) = self.options.get_mut(*i) { 103 | option.active = true; 104 | self.selected_count += 1; 105 | } 106 | } 107 | 108 | self 109 | } 110 | 111 | /// Set whether the cursor should go to the first option when it reaches the last option and vice-versa. 112 | pub fn in_loop(&mut self, is_loop: bool) -> &mut Self { 113 | self.input.set_loop_mode(is_loop); 114 | self 115 | } 116 | 117 | /// Set number of items per page to display. 118 | pub fn items_per_page(&mut self, items_per_page: usize) -> &mut Self { 119 | self.input.set_items_per_page(items_per_page); 120 | self 121 | } 122 | 123 | /// Set minimum number of items required to be selected. 124 | pub fn min(&mut self, min: usize) -> &mut Self { 125 | self.min = Some(min); 126 | self 127 | } 128 | 129 | /// Set maximum number of items allowed to be selected. 130 | pub fn max(&mut self, max: usize) -> &mut Self { 131 | self.max = Some(max); 132 | self 133 | } 134 | 135 | /// Set custom closure to format the prompt. 136 | /// 137 | /// See: [`Customization`](index.html#customization). 138 | pub fn format(&mut self, formatter: F) -> &mut Self 139 | where 140 | F: Fn(&MultiSelect, DrawTime) -> String + 'a, 141 | { 142 | self.formatter = Box::new(formatter); 143 | self 144 | } 145 | 146 | /// Display the prompt and return the user answer. 147 | pub fn prompt(&mut self) -> io::Result> { 148 | key_listener::listen(self, true)?; 149 | 150 | let (selected, _): (Vec<_>, Vec<_>) = self.options.drain(..).partition(|x| x.active); 151 | let selected = selected.into_iter().map(|x| x.value).collect(); 152 | 153 | Ok(selected) 154 | } 155 | } 156 | 157 | impl MultiSelect<'_, T> { 158 | fn toggle_focused(&mut self) { 159 | let selected = self.input.focused; 160 | let focused = &self.options[selected]; 161 | 162 | if focused.disabled { 163 | return; 164 | } 165 | 166 | let under_limit = match self.max { 167 | None => true, 168 | Some(max) => self.selected_count < max, 169 | }; 170 | 171 | let focused = &mut self.options[selected]; 172 | 173 | if focused.active { 174 | focused.active = false; 175 | self.selected_count -= 1; 176 | } else if under_limit { 177 | focused.active = true; 178 | self.selected_count += 1; 179 | } 180 | } 181 | 182 | /// Only submit if the minimum are selected 183 | fn validate_to_submit(&self) -> bool { 184 | match self.min { 185 | None => true, 186 | Some(min) => self.selected_count >= min, 187 | } 188 | } 189 | } 190 | 191 | impl Typeable for MultiSelect<'_, T> { 192 | fn handle_key(&mut self, key: KeyEvent) -> bool { 193 | let mut submit = false; 194 | 195 | match key.code { 196 | // submit 197 | KeyCode::Enter | KeyCode::Backspace => submit = self.validate_to_submit(), 198 | // select/unselect 199 | KeyCode::Char(' ') => self.toggle_focused(), 200 | // update focus 201 | KeyCode::Up | KeyCode::Char('k' | 'K') => self.input.move_cursor(Direction::Up), 202 | KeyCode::Down | KeyCode::Char('j' | 'J') => self.input.move_cursor(Direction::Down), 203 | KeyCode::Left | KeyCode::Char('h' | 'H') => self.input.move_cursor(Direction::Left), 204 | KeyCode::Right | KeyCode::Char('l' | 'L') => self.input.move_cursor(Direction::Right), 205 | _ => (), 206 | } 207 | 208 | submit 209 | } 210 | } 211 | 212 | impl Printable for MultiSelect<'_, T> { 213 | fn draw(&self, renderer: &mut Renderer) -> io::Result<()> { 214 | let text = (self.formatter)(self, renderer.draw_time); 215 | renderer.print(text) 216 | } 217 | } 218 | 219 | #[cfg(test)] 220 | mod tests { 221 | use super::*; 222 | 223 | #[test] 224 | fn set_selected_values() { 225 | let mut prompt = MultiSelect::new("", ["a", "b", "c"]); 226 | 227 | prompt.selected(&[0, 2]); 228 | assert!(prompt.options[0].active); 229 | assert!(prompt.options[2].active); 230 | } 231 | 232 | #[test] 233 | fn set_min() { 234 | let mut prompt = MultiSelect::<&str>::new("", vec![]); 235 | 236 | prompt.min(2); 237 | 238 | assert_eq!(prompt.min, Some(2)); 239 | } 240 | 241 | #[test] 242 | fn set_max() { 243 | let mut prompt = MultiSelect::<&str>::new("", vec![]); 244 | 245 | prompt.max(2); 246 | 247 | assert_eq!(prompt.max, Some(2)); 248 | } 249 | 250 | #[test] 251 | fn set_in_loop() { 252 | let mut prompt = MultiSelect::new("", ["a", "b", "c"]); 253 | 254 | prompt.in_loop(false); 255 | assert!(!prompt.input.loop_mode); 256 | prompt.in_loop(true); 257 | assert!(prompt.input.loop_mode); 258 | } 259 | 260 | #[test] 261 | fn set_custom_formatter() { 262 | let mut prompt: MultiSelect = MultiSelect::new("", vec![]); 263 | let draw_time = DrawTime::First; 264 | const EXPECTED_VALUE: &str = "foo"; 265 | 266 | prompt.format(|_, _| String::from(EXPECTED_VALUE)); 267 | 268 | assert_eq!((prompt.formatter)(&prompt, draw_time), EXPECTED_VALUE); 269 | } 270 | 271 | #[test] 272 | fn submit_keys() { 273 | let events = [KeyCode::Enter, KeyCode::Backspace]; 274 | 275 | for event in events { 276 | let mut prompt = MultiSelect::new("", ["a", "b", "c"]); 277 | let simulated_key = KeyEvent::from(event); 278 | 279 | let submit = prompt.handle_key(simulated_key); 280 | assert!(submit); 281 | } 282 | } 283 | 284 | #[test] 285 | fn not_submit_without_min() { 286 | let mut prompt = MultiSelect::new("", ["a", "b", "c"]); 287 | 288 | prompt.min(1); 289 | let mut submit = prompt.handle_key(KeyEvent::from(KeyCode::Enter)); 290 | 291 | assert!(!submit); 292 | 293 | prompt.handle_key(KeyEvent::from(KeyCode::Char(' '))); 294 | submit = prompt.handle_key(KeyEvent::from(KeyCode::Enter)); 295 | 296 | assert!(submit); 297 | } 298 | 299 | #[test] 300 | fn move_cursor() { 301 | let mut prompt = MultiSelect::new("", ["a", "b", "c"]); 302 | let prev_keys = [KeyCode::Up, KeyCode::Char('k'), KeyCode::Char('K')]; 303 | let next_keys = [KeyCode::Down, KeyCode::Char('j'), KeyCode::Char('j')]; 304 | 305 | // move next 306 | prompt.in_loop(false); 307 | 308 | for key in next_keys { 309 | prompt.input.focused = 0; 310 | prompt.handle_key(KeyEvent::from(key)); 311 | 312 | assert_eq!(prompt.input.focused, 1); 313 | } 314 | 315 | // move next in loop 316 | prompt.in_loop(true); 317 | 318 | for key in next_keys { 319 | prompt.input.focused = 2; 320 | prompt.handle_key(KeyEvent::from(key)); 321 | 322 | assert_eq!(prompt.input.focused, 0); 323 | } 324 | 325 | // move next 326 | prompt.in_loop(false); 327 | 328 | for key in prev_keys { 329 | prompt.input.focused = 2; 330 | prompt.handle_key(KeyEvent::from(key)); 331 | 332 | assert_eq!(prompt.input.focused, 1); 333 | } 334 | 335 | // move next in loop 336 | prompt.in_loop(true); 337 | 338 | for key in prev_keys { 339 | prompt.input.focused = 0; 340 | prompt.handle_key(KeyEvent::from(key)); 341 | 342 | assert_eq!(prompt.input.focused, 2); 343 | } 344 | } 345 | 346 | #[test] 347 | fn update_focused_selected() { 348 | let mut prompt = MultiSelect::new("", ["a", "b", "c"]); 349 | 350 | prompt.max(1); 351 | 352 | assert!(!prompt.options[1].active); 353 | assert!(!prompt.options[2].active); 354 | 355 | prompt.input.focused = 1; 356 | prompt.handle_key(KeyEvent::from(KeyCode::Char(' '))); 357 | 358 | // must not update over limit 359 | prompt.input.focused = 2; 360 | prompt.handle_key(KeyEvent::from(KeyCode::Char(' '))); 361 | 362 | assert!(prompt.options[1].active); 363 | assert!(!prompt.options[2].active); 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /src/prompts/select.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use crossterm::event::{KeyCode, KeyEvent}; 4 | 5 | use crate::utils::{ 6 | key_listener::{self, Typeable}, 7 | renderer::{DrawTime, Printable, Renderer}, 8 | theme, 9 | }; 10 | 11 | pub enum Direction { 12 | Up, 13 | Down, 14 | Left, 15 | Right, 16 | } 17 | 18 | // region: SelectOption 19 | 20 | /// Utility struct to create items for select-like prompts (like [`Select`]). 21 | #[derive(Debug, Clone, PartialEq, Eq, Default)] 22 | pub struct SelectOption<'a, T> { 23 | /// Value that will be returned by the prompt when the user selects the option. 24 | pub value: T, 25 | /// String that will be displayed in the prompt. 26 | pub title: String, 27 | /// Description text to show in the prompt when focus the option. 28 | pub description: Option<&'a str>, 29 | /// Indicate if the option is disabled. 30 | pub disabled: bool, 31 | /// Indicate if the option is active.. 32 | /// 33 | /// **Note**: This field is only used for [`MultiSelect`] prompt, not for [`Select`] prompt. 34 | /// 35 | /// [`MultiSelect`]: crate::MultiSelect 36 | pub active: bool, 37 | } 38 | 39 | impl<'a, T: ToString> SelectOption<'a, T> { 40 | /// Create a new option. 41 | /// 42 | /// * `value`: value that will be returned by the prompt when the user selects the option. 43 | pub fn new(value: T) -> Self { 44 | let title = value.to_string(); 45 | 46 | SelectOption { 47 | value, 48 | title, 49 | description: None, 50 | disabled: false, 51 | active: false, 52 | } 53 | } 54 | 55 | /// Create a new option with a custom title. 56 | /// 57 | /// * `value`: value that will be returned by the prompt when the user selects the option. 58 | /// * `title`: string that will be displayed in the prompt. 59 | pub fn title(mut self, title: &'a str) -> Self { 60 | self.title = title.to_string(); 61 | self 62 | } 63 | 64 | /// Description text to show in the prompt when focus the option. 65 | pub fn description(mut self, description: &'a str) -> Self { 66 | self.description = Some(description); 67 | self 68 | } 69 | 70 | /// Set whether the user can choose this option 71 | pub fn disabled(mut self, disabled: bool) -> Self { 72 | self.disabled = disabled; 73 | self 74 | } 75 | } 76 | 77 | // endregion: SelectOption 78 | 79 | // region: SelectCursor 80 | 81 | /// State of the input for select-like prompts (like [`Select`]). 82 | /// 83 | /// **Note**: This structure is not expected to be created, but it can be consumed when using a custom formatter. 84 | pub struct SelectInput { 85 | /// Focused index of the list. 86 | pub focused: usize, 87 | /// Number of items that must be displayed per page. 88 | pub items_per_page: usize, 89 | /// Indicate if the loop mode is enabled in the prompt. 90 | pub loop_mode: bool, 91 | /// Number of total items in the prompt. 92 | pub total_items: usize, 93 | } 94 | 95 | impl SelectInput { 96 | /// Returns the number of pages in the list. 97 | pub fn count_pages(&self) -> usize { 98 | let total = self.total_items; 99 | let per_page = self.items_per_page; 100 | let rem = total % per_page; 101 | 102 | total / per_page + (rem != 0) as usize 103 | } 104 | 105 | /// Returns the index of the current page. 106 | pub fn get_page(&self) -> usize { 107 | self.focused / self.items_per_page 108 | } 109 | } 110 | 111 | impl SelectInput { 112 | pub(crate) fn new(total_items: usize) -> Self { 113 | SelectInput { 114 | total_items, 115 | focused: 0, 116 | items_per_page: 10, 117 | loop_mode: true, 118 | } 119 | } 120 | 121 | pub(crate) fn set_loop_mode(&mut self, loop_mode: bool) { 122 | self.loop_mode = loop_mode; 123 | } 124 | 125 | pub(crate) fn move_cursor(&mut self, direction: Direction) { 126 | match direction { 127 | Direction::Up => self.prev_item(), 128 | Direction::Down => self.next_item(), 129 | Direction::Left => self.prev_page(), 130 | Direction::Right => self.next_page(), 131 | }; 132 | } 133 | 134 | pub(crate) fn set_items_per_page(&mut self, item_per_page: usize) { 135 | self.items_per_page = item_per_page.min(self.total_items); 136 | } 137 | 138 | fn prev_item(&mut self) { 139 | let max = self.total_items - 1; 140 | 141 | self.focused = match self.loop_mode { 142 | true => self.focused.checked_sub(1).unwrap_or(max), 143 | false => self.focused.saturating_sub(1), 144 | } 145 | } 146 | 147 | fn next_item(&mut self) { 148 | let max = self.total_items - 1; 149 | let new_value = self.focused + 1; 150 | 151 | self.focused = match (new_value > max, self.loop_mode) { 152 | (true, true) => 0, 153 | (true, false) => max, 154 | (false, _) => new_value, 155 | } 156 | } 157 | 158 | fn prev_page(&mut self) { 159 | self.focused = self.focused.saturating_sub(self.items_per_page) 160 | } 161 | 162 | fn next_page(&mut self) { 163 | let max = self.total_items - 1; 164 | let new_value = self.focused + self.items_per_page; 165 | 166 | self.focused = new_value.min(max) 167 | } 168 | } 169 | 170 | // endregion: SelectCursor 171 | 172 | type Formatter<'a, T> = dyn Fn(&Select, DrawTime) -> String + 'a; 173 | 174 | /// Prompt to select an item from a list. 175 | /// 176 | /// To allow choosing multiple items, use the [`MultiSelect`] struct instead. 177 | /// # Key Events 178 | /// 179 | /// | Key | Action | 180 | /// | -------------------- | ---------------------------- | 181 | /// | `Enter`, `Backspace` | Submit current/initial value | 182 | /// | `Up`, `k`, `K` | Focus next item | 183 | /// | `Down`, `j`, `J` | Focus previous item | 184 | /// | `Left`, `h`, `H` | Focus next page | 185 | /// | `Right`, `l`, `L` | Focus previous page | 186 | /// 187 | /// # Examples 188 | /// 189 | /// ```no_run 190 | /// use asky::Select; 191 | /// 192 | /// # fn main() -> std::io::Result<()> { 193 | /// let languages = ["Rust", "Go", "Python", "Javascript", "Brainfuck", "Other"]; 194 | /// let answer = Select::new("What is your favorite language?", languages).prompt()?; 195 | /// # Ok(()) 196 | /// # } 197 | /// ``` 198 | /// [`MultiSelect`]: crate::MultiSelect 199 | pub struct Select<'a, T> { 200 | /// Message used to display in the prompt. 201 | pub message: &'a str, 202 | /// List of options. 203 | pub options: Vec>, 204 | /// Input state. 205 | pub input: SelectInput, 206 | formatter: Box>, 207 | } 208 | 209 | impl<'a, T: 'a> Select<'a, T> { 210 | /// Create a new select prompt. 211 | pub fn new(message: &'a str, iter: I) -> Self 212 | where 213 | I: IntoIterator, 214 | T: ToString, 215 | { 216 | let options = iter.into_iter().map(|o| SelectOption::new(o)).collect(); 217 | Self::new_complex(message, options) 218 | } 219 | 220 | /// Create a new select prompt with custom [`SelectOption`] items. 221 | /// 222 | /// Example: 223 | /// 224 | /// ```no_run 225 | /// use asky::{Select, SelectOption}; 226 | /// 227 | /// # fn main() -> std::io::Result<()> { 228 | /// let options = vec![ 229 | /// SelectOption::new(1), 230 | /// SelectOption::new(2), 231 | /// SelectOption::new(3), 232 | /// SelectOption::new(4).title("Fish"), 233 | /// ]; 234 | /// 235 | /// Select::new_complex("Choose a number", options).prompt()?; 236 | /// # Ok(()) 237 | /// # } 238 | pub fn new_complex(message: &'a str, options: Vec>) -> Self { 239 | let options_len = options.len(); 240 | 241 | Select { 242 | message, 243 | options, 244 | input: SelectInput::new(options_len), 245 | formatter: Box::new(theme::fmt_select), 246 | } 247 | } 248 | 249 | /// Set initial selected index. 250 | pub fn selected(&mut self, index: usize) -> &mut Self { 251 | self.input.focused = index.min(self.options.len() - 1); 252 | self 253 | } 254 | 255 | /// Set whether the cursor should go to the first option when it reaches the last option and vice-versa. 256 | pub fn in_loop(&mut self, loop_mode: bool) -> &mut Self { 257 | self.input.set_loop_mode(loop_mode); 258 | self 259 | } 260 | 261 | /// Set number of items per page to display. 262 | pub fn items_per_page(&mut self, item_per_page: usize) -> &mut Self { 263 | self.input.set_items_per_page(item_per_page); 264 | self 265 | } 266 | 267 | /// Set custom closure to format the prompt. 268 | /// 269 | /// See: [`Customization`](index.html#customization). 270 | pub fn format(&mut self, formatter: F) -> &mut Self 271 | where 272 | F: Fn(&Select, DrawTime) -> String + 'a, 273 | { 274 | self.formatter = Box::new(formatter); 275 | self 276 | } 277 | 278 | /// Display the prompt and return the user answer. 279 | pub fn prompt(&mut self) -> io::Result { 280 | key_listener::listen(self, true)?; 281 | 282 | let selected = self.options.remove(self.input.focused); 283 | 284 | Ok(selected.value) 285 | } 286 | } 287 | 288 | impl Select<'_, T> { 289 | /// Only submit if the option isn't disabled. 290 | fn validate_to_submit(&self) -> bool { 291 | let focused = &self.options[self.input.focused]; 292 | 293 | !focused.disabled 294 | } 295 | } 296 | 297 | impl Typeable for Select<'_, T> { 298 | fn handle_key(&mut self, key: KeyEvent) -> bool { 299 | let mut submit = false; 300 | 301 | match key.code { 302 | // submit 303 | KeyCode::Enter | KeyCode::Backspace => submit = self.validate_to_submit(), 304 | // update value 305 | KeyCode::Up | KeyCode::Char('k' | 'K') => self.input.move_cursor(Direction::Up), 306 | KeyCode::Down | KeyCode::Char('j' | 'J') => self.input.move_cursor(Direction::Down), 307 | KeyCode::Left | KeyCode::Char('h' | 'H') => self.input.move_cursor(Direction::Left), 308 | KeyCode::Right | KeyCode::Char('l' | 'L') => self.input.move_cursor(Direction::Right), 309 | _ => (), 310 | } 311 | 312 | submit 313 | } 314 | } 315 | 316 | impl Printable for Select<'_, T> { 317 | fn draw(&self, renderer: &mut Renderer) -> io::Result<()> { 318 | let text = (self.formatter)(self, renderer.draw_time); 319 | renderer.print(text) 320 | } 321 | } 322 | 323 | #[cfg(test)] 324 | mod tests { 325 | use super::*; 326 | 327 | #[test] 328 | fn set_initial_value() { 329 | let mut prompt = Select::new("", ["foo", "bar"]); 330 | 331 | assert_eq!(prompt.input.focused, 0); 332 | prompt.selected(1); 333 | assert_eq!(prompt.input.focused, 1); 334 | } 335 | 336 | #[test] 337 | fn set_loop_mode() { 338 | let mut prompt = Select::new("", ["foo", "bar"]); 339 | 340 | prompt.in_loop(false); 341 | assert!(!prompt.input.loop_mode); 342 | prompt.in_loop(true); 343 | assert!(prompt.input.loop_mode); 344 | } 345 | 346 | #[test] 347 | fn set_custom_formatter() { 348 | let mut prompt = Select::new("", ["foo", "bar"]); 349 | let draw_time = DrawTime::First; 350 | const EXPECTED_VALUE: &str = "foo"; 351 | 352 | prompt.format(|_, _| String::from(EXPECTED_VALUE)); 353 | 354 | assert_eq!((prompt.formatter)(&prompt, draw_time), EXPECTED_VALUE); 355 | } 356 | 357 | #[test] 358 | fn submit_selected_value() { 359 | let events = [KeyCode::Enter, KeyCode::Backspace]; 360 | 361 | for event in events { 362 | let mut prompt = Select::new("", ["foo", "bar"]); 363 | let simulated_key = KeyEvent::from(event); 364 | 365 | prompt.selected(1); 366 | 367 | let submit = prompt.handle_key(simulated_key); 368 | assert_eq!(prompt.input.focused, 1); 369 | assert!(submit); 370 | } 371 | } 372 | 373 | #[test] 374 | fn not_submit_disabled() { 375 | let events = [KeyCode::Enter, KeyCode::Backspace]; 376 | 377 | for event in events { 378 | let mut prompt = Select::new_complex("", vec![SelectOption::new("foo").disabled(true)]); 379 | 380 | let submit = prompt.handle_key(KeyEvent::from(event)); 381 | assert!(!submit); 382 | } 383 | } 384 | 385 | #[test] 386 | fn update_focused() { 387 | let up_keys = [KeyCode::Up, KeyCode::Char('k'), KeyCode::Char('K')]; 388 | let down_keys = [KeyCode::Down, KeyCode::Char('j'), KeyCode::Char('j')]; 389 | 390 | let up_cases = [ 391 | //in_loop, initial, expected 392 | (false, 0, 0), 393 | (false, 1, 0), 394 | (true, 0, 1), 395 | ]; 396 | let down_cases = [ 397 | //in_loop, initial, expected 398 | (false, 1, 1), 399 | (false, 0, 1), 400 | (true, 1, 0), 401 | ]; 402 | 403 | for key in up_keys { 404 | for (in_loop, initial, expected) in up_cases { 405 | let mut prompt = Select::new("", ["foo", "bar"]); 406 | let simulated_key = KeyEvent::from(key); 407 | 408 | prompt.selected(initial); 409 | prompt.in_loop(in_loop); 410 | prompt.handle_key(simulated_key); 411 | assert_eq!(prompt.input.focused, expected); 412 | } 413 | } 414 | 415 | for key in down_keys { 416 | for (in_loop, initial, expected) in down_cases { 417 | let mut prompt = Select::new("", ["foo", "bar"]); 418 | let simulated_key = KeyEvent::from(key); 419 | 420 | prompt.selected(initial); 421 | prompt.in_loop(in_loop); 422 | prompt.handle_key(simulated_key); 423 | assert_eq!(prompt.input.focused, expected); 424 | } 425 | } 426 | } 427 | } 428 | --------------------------------------------------------------------------------