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/// ```
118
119pub struct MenuOption {
120    pub label: String,
121    pub action: Box<dyn FnMut()>,
122}
123
124impl MenuOption {
125    pub fn new(label: &str, action: impl FnMut() + 'static) -> Self {
126        Self {
127            label: label.to_owned(),
128            action: Box::new(action),
129        }
130    }
131}
132
133/// ```
134/// # use console_menu::MenuOption;
135/// # fn default() -> MenuOption {
136/// MenuOption::new("exit", || {})
137/// # }
138/// ```
139impl Default for MenuOption {
140    fn default() -> MenuOption {
141        MenuOption::new("exit", || {})
142    }
143}
144
145/// Interactive console menu.
146///
147/// Create a menu by passing it a list of `MenuOption` and a `MenuProps`. Display using`.show()`.
148///
149/// ```no_run
150/// # use console_menu::{Menu, MenuOption, MenuProps};
151/// let menu_options = vec![
152///     MenuOption::new("option 1", || println!("option one!")),
153///     MenuOption::new("option 2", || println!("option two!")),
154///     MenuOption::new("option 3", || println!("option three!")),
155/// ];
156/// let mut menu = Menu::new(menu_options, MenuProps::default());
157/// menu.show();
158/// ```
159pub struct Menu {
160    items: Vec<MenuOption>,
161    title: Option<String>,
162    message: Option<String>,
163    exit_on_action: bool,
164    bg_color: u8,
165    fg_color: u8,
166    title_color: u8,
167    selected_color: u8,
168    msg_color: u8,
169    selected_item: usize,
170    selected_page: usize,
171    items_per_page: usize,
172    num_pages: usize,
173    page_start: usize,
174    page_end: usize,
175    max_width: usize,
176}
177
178impl Menu {
179    pub fn new(items: Vec<MenuOption>, props: MenuProps) -> Self {
180        let mut items = items;
181        if items.len() == 0 { items.push(MenuOption::default()) }
182
183        let items_per_page: usize = (Term::stdout().size().0 - 6) as usize;
184        let items_per_page = clamp(items_per_page, 1, items.len());
185        let num_pages = ((items.len() - 1) / items_per_page) + 1;
186
187        let mut max_width = (&items).iter().fold(0, |max, item| {
188            let label_len = item.label.len();
189            if label_len > max { label_len } else { max }
190        });
191        if props.title.len() > max_width {
192            max_width = props.title.len()
193        }
194        if props.message.len() > max_width {
195            max_width = props.message.len()
196        }  
197
198        let mut menu = Self {
199            items,
200            title: if props.title.len() > 0 {
201                Some(props.title.to_owned())
202            } else {
203                None
204            },
205            message: if props.message.len() > 0 {
206                Some(props.message.to_owned())
207            } else {
208                None
209            },
210            exit_on_action: props.exit_on_action,
211            bg_color: props.bg_color,
212            fg_color: props.fg_color,
213            title_color: props.title_color.unwrap_or(props.fg_color),
214            selected_color: props.selected_color.unwrap_or(props.fg_color),
215            msg_color: props.msg_color.unwrap_or(props.fg_color),
216            selected_item: 0,
217            selected_page: 0,
218            items_per_page,
219            num_pages,
220            page_start: 0,
221            page_end: 0,
222            max_width,
223        };
224        menu.set_page(0);
225        menu
226    }
227
228    pub fn show(&mut self) {
229        let stdout = Term::buffered_stdout();
230        stdout.hide_cursor().unwrap();
231
232        let term_height = Term::stdout().size().0 as usize;
233        stdout.write_str(&"\n".repeat(term_height - 1)).unwrap();
234
235        self.draw(&stdout);
236        self.run_navigation(&stdout);
237    }
238
239    fn run_navigation(&mut self, stdout: &Term) {
240        loop {
241            let key = stdout.read_key().unwrap();
242
243            match key {
244                Key::ArrowUp | Key::Char('k') => {
245                    if self.selected_item != self.page_start {
246                        self.selected_item -= 1;
247                    } else if self.selected_page != 0 {
248                        self.set_page(self.selected_page - 1);
249                        self.selected_item = self.page_end;
250                    }
251                }
252                Key::ArrowDown | Key::Char('j') => {
253                    if self.selected_item < self.page_end {
254                        self.selected_item += 1
255                    } else if self.selected_page < self.num_pages - 1 {
256                        self.set_page(self.selected_page + 1);
257                    }
258                }
259                Key::ArrowLeft | Key::Char('h') | Key::Char('b') => {
260                    if self.selected_page != 0 {
261                        self.set_page(self.selected_page - 1);
262                    }
263                }
264                Key::ArrowRight | Key::Char('l') | Key::Char('w') => {
265                    if self.selected_page < self.num_pages - 1 {
266                        self.set_page(self.selected_page + 1);
267                    }
268                }
269                Key::Escape | Key::Char('q') | Key::Backspace => {
270                    self.exit(stdout);
271                    break;
272                }
273                Key::Enter => {
274                    if self.exit_on_action {
275                        self.exit(stdout);
276                        (self.items[self.selected_item].action)();
277                        break;
278                    } else {
279                        (self.items[self.selected_item].action)();
280                    }    
281                }
282                _ => {}
283            }
284
285            self.draw(stdout);
286        }
287    }
288
289    fn set_page(&mut self, page: usize) {
290        self.selected_page = page;
291        self.page_start = self.selected_page * self.items_per_page;
292        self.selected_item = self.page_start;
293        if self.items.len() > self.page_start + self.items_per_page {
294            self.page_end = self.page_start + self.items_per_page - 1
295        } else {
296            self.page_end = self.items.len() - 1
297        }
298    }
299
300    fn draw(&self, stdout: &Term) {
301        clear_screen(stdout);
302
303        let menu_width = self.max_width;
304        let mut extra_lines = 2;
305        if let Some(_) = self.title {
306           extra_lines += 2; 
307        }
308        if let Some(_) = self.message {
309            extra_lines += 1;
310        }
311
312        let indent: usize = (stdout.size().1 / 2) as usize - ((menu_width + 4) / 2);
313        let indent_str = pad_left("".to_string(), indent);
314
315        let vertical_pad: usize = (stdout.size().0 / 2) as usize  - ((self.items_per_page + extra_lines) / 2);
316        stdout.write_str(&format!("{:\n<width$}", "", width=vertical_pad)).unwrap();
317
318        stdout.write_str(&format!("\x1b[38;5;{}m", self.fg_color)).unwrap(); // set foreground color
319        stdout.write_line(&format!("{}{}", indent_str, self.apply_bg("", menu_width))).unwrap();
320
321        let mut ansi_width = 34 + num_digs(self.fg_color) + num_digs(self.title_color);
322        if let Some(title) = &self.title {
323            let title_str = format!("\x1b[4m{}\x1b[24m", self.apply_bold(title)); // apply bold + underline
324            stdout.write_line(&format!("{}{}", indent_str, self.apply_bg(&self.switch_fg(&title_str, self.title_color), menu_width + ansi_width))).unwrap();
325            stdout.write_line(&format!("{}{}", indent_str, self.apply_bg("", menu_width))).unwrap();
326        } 
327
328        for (i, option) in self.items[self.page_start..=self.page_end].iter().enumerate() {
329            let item_str = if self.page_start + i == self.selected_item {
330                ansi_width = 25 + num_digs(self.fg_color) + num_digs(self.selected_color);
331                format!("{}", self.switch_fg(&self.apply_bold(&option.label), self.selected_color))
332            } else {
333                ansi_width = 0;
334                format!("{}", option.label)
335            };
336            stdout.write_line(&format!("{}{}", indent_str, self.apply_bg(&item_str, menu_width + ansi_width))).unwrap();
337        }
338
339        if self.num_pages > 1 {
340            stdout.write_line(&format!("{}{}", indent_str, self.apply_bg(&format!("Page {} of {}", self.selected_page + 1, self.num_pages), menu_width))).unwrap();
341        }
342        if let Some(message) = &self.message {
343            stdout.write_line(&format!("{}{}", indent_str, self.apply_bg("", menu_width))).unwrap();
344            stdout.write_line(&format!("{}{}", indent_str, self.switch_fg(&self.apply_bg(message, menu_width), self.msg_color))).unwrap();
345        }
346
347        stdout.write_line(&format!("{}{}", indent_str, self.apply_bg("", menu_width))).unwrap();
348        stdout.write_str("\x1b[39m").unwrap(); // reset foreground color
349
350        stdout.flush().unwrap();
351    }
352
353
354    fn apply_bold(&self, s: &str) -> String { // 9 ansi chars
355        format!("\x1b[1m{}\x1b[22m", s)
356    }
357
358    fn switch_fg(&self, s: &str, color: u8) -> String { // 16 + (fg digs + switch digs) ansi chars
359        format!("\x1b[38;5;{}m{}\x1b[38;5;{}m", color, s, self.fg_color)
360    }
361
362    fn apply_bg(&self, s: &str, width: usize) -> String {
363        format!("\x1b[48;5;{}m{}\x1b[49m", self.bg_color, pad_right(format!("  {}", s), width + 4)) 
364    }
365
366
367    fn exit(&self, stdout: &Term) {
368        clear_screen(stdout);
369        stdout.show_cursor().unwrap();
370        stdout.flush().unwrap();
371    }
372}
373
374
375fn clear_screen(stdout: &Term) {
376    stdout.write_str("\x1b[H\x1b[J\x1b[H").unwrap();
377}
378
379fn pad_left(s: String, width: usize) -> String {
380    format!("{: >width$}", s, width=width)
381}
382
383fn pad_right(s: String, width: usize) -> String {
384    format!("{: <width$}", s, width=width)
385}
386
387fn clamp(num: usize, min: usize, max: usize) -> usize {
388    let out = if num < min { min } else { num };
389    if out > max { max } else { out }
390}
391
392fn num_digs(num: u8) -> usize {
393    (num.checked_ilog10().unwrap_or(0) + 1) as usize
394}