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 {
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
133impl Default for MenuOption {
140 fn default() -> MenuOption {
141 MenuOption::new("exit", || {})
142 }
143}
144
145pub 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(); 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)); 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(); stdout.flush().unwrap();
351 }
352
353
354 fn apply_bold(&self, s: &str) -> String { format!("\x1b[1m{}\x1b[22m", s)
356 }
357
358 fn switch_fg(&self, s: &str, color: u8) -> String { 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}