1use console::{Key, Term};
29
30pub 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
45pub struct MenuProps<'a> {
60 pub title: &'a str,
62 pub message: &'a str,
64 pub exit_on_action: bool,
66 pub bg_color: u8,
68 pub fg_color: u8,
70 pub title_color: Option<u8>,
72 pub selected_color: Option<u8>,
74 pub msg_color: Option<u8>,
76}
77
78impl 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
108pub 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
132impl Default for MenuOption {
139 fn default() -> MenuOption {
140 MenuOption::new("exit", || {})
141 }
142}
143
144pub 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(); 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)); 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(); stdout.flush().unwrap();
340 }
341
342
343 fn apply_bold(&self, s: &str) -> String { format!("\x1b[1m{}\x1b[22m", s)
345 }
346
347 fn switch_fg(&self, s: &str, color: u8) -> String { 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}