console_menu/
lib.rs

1//! A simple yet powerful library for creating beautiful console menus in rust.
2//!
3//! Allows for easy creation of interactive console menus. A simple example:
4//!
5//! ```no_run
6//! use console_menu::{Menu, MenuOption, MenuProps};
7//! 
8//! let menu_options = vec![
9//!     MenuOption::new("option 1", || println!("option one!")),
10//!     MenuOption::new("option 2", || println!("option two!")),
11//!     MenuOption::new("option 3", || println!("option three!")),
12//! ];
13//! let mut menu = Menu::new(menu_options, MenuProps::default());
14//! menu.show();
15//! ```
16//!
17//! Menus can include a title, footer message, and any combination of [8-bit](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit)
18//! colored backgrounds and text by configuring `MenuProps`. Menus that don't fit the console window are paginated.
19//!
20//! Menu controls are as follows:
21//! 
22//! | Key Bind | Action      |
23//! | -------- | ----------- |
24//! | ↓, ↑, ←, →, h, j, k, l | make selection        |
25//! | enter    | confirm     |
26//! | esc, q   | exit        |
27
28use console::{Key, Term};
29
30/// A collection of pre-selected color values to simplify menu theming.
31pub mod color {
32    pub const WHITE: u8 = 15;
33    pub const LIGHT_GRAY: u8 = 7;
34    pub const GRAY: u8 = 8;
35    pub const BLUE: u8 = 32;
36    pub const GREEN: u8 = 35;
37    pub const PURPLE: u8 = 99;
38    pub const RED: u8 = 160;
39    pub const ORANGE: u8 = 208;
40    pub const YELLOW: u8 = 220;
41    pub const BLACK: u8 = 233;
42    pub const DARK_GRAY:u8 = 236;
43}
44
45/// Stores configuration data passed to a `Menu` on creation.
46///
47/// Menus use [8-bit](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) colors to ensure
48/// widespread terminal support. It should be noted that values from 0-15 will make colors vary
49/// based on individual terminal settings.
50///
51/// Configure a subset of properties using the defaults and struct update syntax:
52/// ```
53/// # use console_menu::MenuProps;
54/// let props = MenuProps {
55///     title: "My Menu",
56///     ..MenuProps::default()
57/// };
58/// ```
59pub struct MenuProps<'a> {
60    /// Displays above the list of menu options. Pass an empty string for no title.
61    pub title: &'a str,
62    /// Display below the list of menu options. Pass an empty string for no message.
63    pub message: &'a str,
64    /// If true, menu will exit immediately upon an option being selected.
65    pub exit_on_action: bool,
66    /// The background color for the menu.
67    pub bg_color: u8,
68    /// The foreground (text) color for the menu.
69    pub fg_color: u8,
70    /// Optional color for the title. If None, the foreground color will be used.
71    pub title_color: Option<u8>,
72    /// Optional color for the selected menu option. If None, the foreground color will be used.
73    pub selected_color: Option<u8>,
74    /// Optional color for the footer message. If None, the foreground color will be used.
75    pub msg_color: Option<u8>,
76}
77
78/// ```
79/// # use console_menu::MenuProps;
80/// # fn default() -> MenuProps<'static> {
81/// MenuProps {
82///     title: "",
83///     message: "",
84///     exit_on_action: true,
85///     bg_color: 8,
86///     fg_color: 15,
87///     title_color: None,
88///     selected_color: None,
89///     msg_color: Some(7),
90/// }
91/// # }
92/// ```
93impl Default for MenuProps<'_> {
94    fn default() -> MenuProps<'static> {
95        MenuProps {
96            title: "",
97            message: "",
98            exit_on_action: true,
99            bg_color: 8,
100            fg_color: 15,
101            title_color: None,
102            selected_color: None,
103            msg_color: Some(7),
104        }
105    }
106}
107
108/// An element in a `Menu`.
109///
110/// Consists of a label and a callback. Callbacks can be any function, including functions that
111/// call nested menus:
112///
113/// ```
114/// # use console_menu::{Menu, MenuOption, MenuProps};
115/// let mut nested_menu = Menu::new(vec![], MenuProps::default());
116/// let show_nested = MenuOption::new("show nested menu", move || nested_menu.show());
117/// ```
118pub struct MenuOption {
119    pub label: String,
120    pub action: Box<dyn FnMut()>,
121}
122
123impl MenuOption {
124    pub fn new(label: &str, action: impl FnMut() + 'static) -> Self {
125        Self {
126            label: label.to_owned(),
127            action: Box::new(action),
128        }
129    }
130}
131
132/// ```
133/// # use console_menu::MenuOption;
134/// # fn default() -> MenuOption {
135/// MenuOption::new("exit", || {})
136/// # }
137/// ```
138impl Default for MenuOption {
139    fn default() -> MenuOption {
140        MenuOption::new("exit", || {})
141    }
142}
143
144/// Interactive console menu.
145///
146/// Create a menu by passing it a list of `MenuOption` and a `MenuProps`. Display using`.show()`.
147///
148/// ```no_run
149/// # use console_menu::{Menu, MenuOption, MenuProps};
150/// let menu_options = vec![
151///     MenuOption::new("option 1", || println!("option one!")),
152///     MenuOption::new("option 2", || println!("option two!")),
153///     MenuOption::new("option 3", || println!("option three!")),
154/// ];
155/// let mut menu = Menu::new(menu_options, MenuProps::default());
156/// menu.show();
157/// ```
158pub struct Menu {
159    options: Vec<MenuOption>,
160    title: Option<String>,
161    message: Option<String>,
162    exit_on_action: bool,
163    bg_color: u8,
164    fg_color: u8,
165    title_color: u8,
166    selected_color: u8,
167    msg_color: u8,
168    selected_option: usize,
169    selected_page: usize,
170    options_per_page: usize,
171    num_pages: usize,
172    page_start: usize,
173    page_end: usize,
174    max_width: usize,
175}
176
177impl Menu {
178    pub fn new(options: Vec<MenuOption>, props: MenuProps) -> Self {
179        assert!(!options.is_empty(), "Menu options cannot be empty!");
180
181        let options_per_page: usize = (Term::stdout().size().0 - 6) as usize;
182        let options_per_page = clamp(options_per_page, 1, options.len());
183        let num_pages = ((options.len() - 1) / options_per_page) + 1;
184
185        let mut max_width = options.iter().fold(0, |max, option| {
186            let label_len = option.label.len();
187            if label_len > max { label_len } else { max }
188        });
189        if props.title.len() > max_width {
190            max_width = props.title.len()
191        }
192        if props.message.len() > max_width {
193            max_width = props.message.len()
194        }
195
196        let mut menu = Self {
197            options,
198            title: (!props.title.is_empty()).then(|| props.title.to_owned()),
199            message: (!props.message.is_empty()).then(|| props.title.to_owned()),
200            exit_on_action: props.exit_on_action,
201            bg_color: props.bg_color,
202            fg_color: props.fg_color,
203            title_color: props.title_color.unwrap_or(props.fg_color),
204            selected_color: props.selected_color.unwrap_or(props.fg_color),
205            msg_color: props.msg_color.unwrap_or(props.fg_color),
206            selected_option: 0,
207            selected_page: 0,
208            options_per_page,
209            num_pages,
210            page_start: 0,
211            page_end: 0,
212            max_width,
213        };
214        menu.set_page(0);
215        menu
216    }
217
218    pub fn show(&mut self) {
219        let stdout = Term::buffered_stdout();
220        stdout.hide_cursor().unwrap();
221
222        let term_height = Term::stdout().size().0 as usize;
223        stdout.write_str(&"\n".repeat(term_height - 1)).unwrap();
224
225        self.draw(&stdout);
226        self.run_navigation(&stdout);
227    }
228
229    fn run_navigation(&mut self, stdout: &Term) {
230        loop {
231            let key = stdout.read_key().unwrap();
232
233            match key {
234                Key::ArrowUp | Key::Char('k') => {
235                    if self.selected_option != self.page_start {
236                        self.selected_option -= 1;
237                    } else if self.selected_page != 0 {
238                        self.set_page(self.selected_page - 1);
239                        self.selected_option = self.page_end;
240                    }
241                }
242                Key::ArrowDown | Key::Char('j') => {
243                    if self.selected_option < self.page_end {
244                        self.selected_option += 1
245                    } else if self.selected_page < self.num_pages - 1 {
246                        self.set_page(self.selected_page + 1);
247                    }
248                }
249                Key::ArrowLeft | Key::Char('h') | Key::Char('b') => {
250                    if self.selected_page != 0 {
251                        self.set_page(self.selected_page - 1);
252                    }
253                }
254                Key::ArrowRight | Key::Char('l') | Key::Char('w') => {
255                    if self.selected_page < self.num_pages - 1 {
256                        self.set_page(self.selected_page + 1);
257                    }
258                }
259                Key::Escape | Key::Char('q') | Key::Backspace => {
260                    self.exit(stdout);
261                    break;
262                }
263                Key::Enter => {
264                    if self.exit_on_action {
265                        self.exit(stdout);
266                        (self.options[self.selected_option].action)();
267                        break;
268                    }
269                    (self.options[self.selected_option].action)();
270                }
271                _ => {}
272            }
273
274            self.draw(stdout);
275        }
276    }
277
278    fn set_page(&mut self, page: usize) {
279        self.selected_page = page;
280        self.page_start = self.selected_page * self.options_per_page;
281        self.selected_option = self.page_start;
282        if self.options.len() > self.page_start + self.options_per_page {
283            self.page_end = self.page_start + self.options_per_page - 1
284        } else {
285            self.page_end = self.options.len() - 1
286        }
287    }
288
289    fn draw(&self, stdout: &Term) {
290        clear_screen(stdout);
291
292        let menu_width = self.max_width;
293        let mut extra_lines = 2;
294        if let Some(_) = self.title {
295           extra_lines += 2; 
296        }
297        if let Some(_) = self.message {
298            extra_lines += 1;
299        }
300
301        let indent: usize = (stdout.size().1 / 2) as usize - ((menu_width + 4) / 2);
302        let indent_str = pad_left("".to_string(), indent);
303
304        let vertical_pad: usize = (stdout.size().0 / 2) as usize  - ((self.options_per_page + extra_lines) / 2);
305        stdout.write_str(&format!("{:\n<width$}", "", width=vertical_pad)).unwrap();
306
307        stdout.write_str(&format!("\x1b[38;5;{}m", self.fg_color)).unwrap(); // set foreground color
308        stdout.write_line(&format!("{}{}", indent_str, self.apply_bg("", menu_width))).unwrap();
309
310        let mut ansi_width = 34 + num_digs(self.fg_color) + num_digs(self.title_color);
311        if let Some(title) = &self.title {
312            let title_str = format!("\x1b[4m{}\x1b[24m", self.apply_bold(title)); // apply bold + underline
313            stdout.write_line(&format!("{}{}", indent_str, self.apply_bg(&self.switch_fg(&title_str, self.title_color), menu_width + ansi_width))).unwrap();
314            stdout.write_line(&format!("{}{}", indent_str, self.apply_bg("", menu_width))).unwrap();
315        } 
316
317        for (i, option) in self.options[self.page_start..=self.page_end].iter().enumerate() {
318            let option_str = if self.page_start + i == self.selected_option {
319                ansi_width = 25 + num_digs(self.fg_color) + num_digs(self.selected_color);
320                format!("{}", self.switch_fg(&self.apply_bold(&option.label), self.selected_color))
321            } else {
322                ansi_width = 0;
323                format!("{}", option.label)
324            };
325            stdout.write_line(&format!("{}{}", indent_str, self.apply_bg(&option_str, menu_width + ansi_width))).unwrap();
326        }
327
328        if self.num_pages > 1 {
329            stdout.write_line(&format!("{}{}", indent_str, self.apply_bg(&format!("Page {} of {}", self.selected_page + 1, self.num_pages), menu_width))).unwrap();
330        }
331        if let Some(message) = &self.message {
332            stdout.write_line(&format!("{}{}", indent_str, self.apply_bg("", menu_width))).unwrap();
333            stdout.write_line(&format!("{}{}", indent_str, self.switch_fg(&self.apply_bg(message, menu_width), self.msg_color))).unwrap();
334        }
335
336        stdout.write_line(&format!("{}{}", indent_str, self.apply_bg("", menu_width))).unwrap();
337        stdout.write_str("\x1b[39m").unwrap(); // reset foreground color
338
339        stdout.flush().unwrap();
340    }
341
342
343    fn apply_bold(&self, s: &str) -> String { // 9 ansi chars
344        format!("\x1b[1m{}\x1b[22m", s)
345    }
346
347    fn switch_fg(&self, s: &str, color: u8) -> String { // 16 + (fg digs + switch digs) ansi chars
348        format!("\x1b[38;5;{}m{}\x1b[38;5;{}m", color, s, self.fg_color)
349    }
350
351    fn apply_bg(&self, s: &str, width: usize) -> String {
352        format!("\x1b[48;5;{}m{}\x1b[49m", self.bg_color, pad_right(format!("  {}", s), width + 4)) 
353    }
354
355
356    fn exit(&self, stdout: &Term) {
357        clear_screen(stdout);
358        stdout.show_cursor().unwrap();
359        stdout.flush().unwrap();
360    }
361}
362
363
364fn clear_screen(stdout: &Term) {
365    stdout.write_str("\x1b[H\x1b[J\x1b[H").unwrap();
366}
367
368fn pad_left(s: String, width: usize) -> String {
369    format!("{: >width$}", s, width=width)
370}
371
372fn pad_right(s: String, width: usize) -> String {
373    format!("{: <width$}", s, width=width)
374}
375
376fn clamp(num: usize, min: usize, max: usize) -> usize {
377    let out = if num < min { min } else { num };
378    if out > max { max } else { out }
379}
380
381fn num_digs(num: u8) -> usize {
382    (num.checked_ilog10().unwrap_or(0) + 1) as usize
383}