rat_menu/
menuline.rs

1//!
2//! A main menu widget.
3//!
4//! ```
5//! use crossterm::event::Event;
6//! use ratatui::buffer::Buffer;
7//! use ratatui::layout::Rect;
8//! use ratatui::widgets::StatefulWidget;
9//! use rat_event::Outcome;
10//! use rat_menu::event::MenuOutcome;
11//! use rat_menu::menuline;
12//! use rat_menu::menuline::{MenuLine, MenuLineState};
13//!
14//! # struct State { menu: MenuLineState }
15//! # let mut state = State { menu: Default::default() };
16//! # let mut buf = Buffer::default();
17//! # let buf = &mut buf;
18//! # let area = Rect::default();
19//!
20//! MenuLine::new()
21//!         .title("Sample")
22//!         .item_parsed("_File")
23//!         .item_parsed("E_dit")
24//!         .item_parsed("_View")
25//!         .item_parsed("_Quit")
26//!         .render(area, buf, &mut state.menu);
27//! ```
28use crate::_private::NonExhaustive;
29use crate::event::MenuOutcome;
30use crate::util::revert_style;
31use crate::{MenuBuilder, MenuItem, MenuStyle};
32use rat_event::util::MouseFlags;
33use rat_event::{HandleEvent, MouseOnly, Regular, ct_event};
34use rat_focus::{FocusBuilder, FocusFlag, HasFocus};
35use ratatui::buffer::Buffer;
36use ratatui::layout::Rect;
37use ratatui::style::{Style, Stylize};
38use ratatui::text::{Line, Span};
39use ratatui::widgets::{StatefulWidget, Widget};
40use std::fmt::Debug;
41
42/// Main menu widget.
43#[derive(Debug, Default, Clone)]
44pub struct MenuLine<'a> {
45    pub(crate) menu: MenuBuilder<'a>,
46    title: Line<'a>,
47    style: Style,
48    highlight_style: Option<Style>,
49    disabled_style: Option<Style>,
50    right_style: Option<Style>,
51    title_style: Option<Style>,
52    focus_style: Option<Style>,
53}
54
55/// State & event handling.
56#[derive(Debug)]
57pub struct MenuLineState {
58    /// Area for the whole widget.
59    /// __readonly__. renewed for each render.
60    pub area: Rect,
61    /// Areas for each item.
62    /// __readonly__. renewed for each render.
63    pub item_areas: Vec<Rect>,
64    /// Hot keys
65    /// __readonly__. renewed for each render.
66    pub navchar: Vec<Option<char>>,
67    /// Disable menu-items.
68    /// __readonly__. renewed for each render.
69    pub disabled: Vec<bool>,
70    /// Selected item.
71    /// __read+write__
72    pub selected: Option<usize>,
73    /// Current focus state.
74    /// __read+write__
75    pub focus: FocusFlag,
76
77    /// Flags for mouse handling.
78    /// __used for mouse interaction__
79    pub mouse: MouseFlags,
80
81    pub non_exhaustive: NonExhaustive,
82}
83
84impl<'a> MenuLine<'a> {
85    /// New
86    pub fn new() -> Self {
87        Default::default()
88    }
89
90    /// Title text.
91    #[inline]
92    pub fn title(mut self, title: impl Into<Line<'a>>) -> Self {
93        self.title = title.into();
94        self
95    }
96
97    /// Add an item.
98    pub fn item(mut self, item: MenuItem<'a>) -> Self {
99        self.menu.item(item);
100        self
101    }
102
103    /// Parse the text.
104    ///
105    /// __See__
106    ///
107    /// [MenuItem::new_parsed]
108    pub fn item_parsed(mut self, text: &'a str) -> Self {
109        self.menu.item_parsed(text);
110        self
111    }
112
113    /// Add a text-item.
114    pub fn item_str(mut self, txt: &'a str) -> Self {
115        self.menu.item_str(txt);
116        self
117    }
118
119    /// Add an owned text as item.
120    pub fn item_string(mut self, txt: String) -> Self {
121        self.menu.item_string(txt);
122        self
123    }
124
125    /// Combined style.
126    #[inline]
127    pub fn styles(mut self, styles: MenuStyle) -> Self {
128        self.style = styles.style;
129        if styles.highlight.is_some() {
130            self.highlight_style = styles.highlight;
131        }
132        if styles.disabled.is_some() {
133            self.disabled_style = styles.disabled;
134        }
135        if styles.right.is_some() {
136            self.right_style = styles.right;
137        }
138        if styles.focus.is_some() {
139            self.focus_style = styles.focus;
140        }
141        if styles.title.is_some() {
142            self.title_style = styles.title;
143        }
144        if styles.focus.is_some() {
145            self.focus_style = styles.focus;
146        }
147        self
148    }
149
150    /// Base style.
151    #[inline]
152    pub fn style(mut self, style: Style) -> Self {
153        self.style = style;
154        self
155    }
156
157    /// Shortcut highlight style.
158    #[inline]
159    pub fn highlight_style(mut self, style: Style) -> Self {
160        self.highlight_style = Some(style);
161        self
162    }
163
164    /// Shortcut highlight style.
165    #[inline]
166    pub fn highlight_style_opt(mut self, style: Option<Style>) -> Self {
167        self.highlight_style = style;
168        self
169    }
170
171    /// Disabled item style.
172    #[inline]
173    pub fn disabled_style(mut self, style: Style) -> Self {
174        self.disabled_style = Some(style);
175        self
176    }
177
178    /// Disabled item style.
179    #[inline]
180    pub fn disabled_style_opt(mut self, style: Option<Style>) -> Self {
181        self.disabled_style = style;
182        self
183    }
184
185    /// Style for the hotkey.
186    #[inline]
187    pub fn right_style(mut self, style: Style) -> Self {
188        self.right_style = Some(style);
189        self
190    }
191
192    /// Style for the hotkey.
193    #[inline]
194    pub fn right_style_opt(mut self, style: Option<Style>) -> Self {
195        self.right_style = style;
196        self
197    }
198
199    /// Menu-title style.
200    #[inline]
201    pub fn title_style(mut self, style: Style) -> Self {
202        self.title_style = Some(style);
203        self
204    }
205
206    /// Menu-title style.
207    #[inline]
208    pub fn title_style_opt(mut self, style: Option<Style>) -> Self {
209        self.title_style = style;
210        self
211    }
212
213    /// Selection
214    #[inline]
215    #[deprecated(since = "1.1.0", note = "merged with focus style")]
216    pub fn select_style(self, _style: Style) -> Self {
217        self
218    }
219
220    /// Selection
221    #[inline]
222    #[deprecated(since = "1.1.0", note = "merged with focus style")]
223    pub fn select_style_opt(self, _style: Option<Style>) -> Self {
224        self
225    }
226
227    /// Selection + Focus
228    #[inline]
229    pub fn focus_style(mut self, style: Style) -> Self {
230        self.focus_style = Some(style);
231        self
232    }
233
234    /// Selection + Focus
235    #[inline]
236    pub fn focus_style_opt(mut self, style: Option<Style>) -> Self {
237        self.focus_style = style;
238        self
239    }
240}
241
242impl<'a> StatefulWidget for &MenuLine<'a> {
243    type State = MenuLineState;
244
245    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
246        render_ref(self, area, buf, state);
247    }
248}
249
250impl StatefulWidget for MenuLine<'_> {
251    type State = MenuLineState;
252
253    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
254        render_ref(&self, area, buf, state);
255    }
256}
257
258fn render_ref(widget: &MenuLine<'_>, area: Rect, buf: &mut Buffer, state: &mut MenuLineState) {
259    state.area = area;
260    state.item_areas.clear();
261
262    if widget.menu.items.is_empty() {
263        state.selected = None;
264    } else if state.selected.is_none() {
265        state.selected = Some(0);
266    }
267
268    state.navchar = widget
269        .menu
270        .items
271        .iter()
272        .map(|v| v.navchar.map(|w| w.to_ascii_lowercase()))
273        .collect();
274    state.disabled = widget.menu.items.iter().map(|v| v.disabled).collect();
275
276    let style = widget.style;
277    let right_style = style.patch(widget.right_style.unwrap_or_default());
278    let highlight_style = style.patch(widget.highlight_style.unwrap_or(Style::new().underlined()));
279    let disabled_style = style.patch(widget.disabled_style.unwrap_or_default());
280
281    let (sel_style, sel_right_style, sel_highlight_style, sel_disabled_style) =
282        if state.is_focused() {
283            let focus_style = widget.focus_style.unwrap_or(revert_style(style));
284            (
285                focus_style,
286                focus_style.patch(right_style),
287                focus_style,
288                focus_style.patch(widget.disabled_style.unwrap_or_default()),
289            )
290        } else {
291            (
292                style, //
293                right_style,
294                highlight_style,
295                disabled_style,
296            )
297        };
298
299    let title_style = if let Some(title_style) = widget.title_style {
300        title_style
301    } else {
302        style.underlined()
303    };
304
305    buf.set_style(area, style);
306
307    let mut item_area = Rect::new(area.x, area.y, 0, 1);
308
309    if widget.title.width() > 0 {
310        item_area.width = widget.title.width() as u16;
311
312        buf.set_style(item_area, title_style);
313        widget.title.clone().render(item_area, buf);
314
315        item_area.x += item_area.width + 1;
316    }
317
318    for (n, item) in widget.menu.items.iter().enumerate() {
319        item_area.width =
320            item.item_width() + item.right_width() + if item.right.is_empty() { 0 } else { 2 };
321        if item_area.right() >= area.right() {
322            item_area = item_area.clamp(area);
323        }
324        state.item_areas.push(item_area);
325
326        #[allow(clippy::collapsible_else_if)]
327        let (style, right_style, highlight_style) = if state.selected == Some(n) {
328            if item.disabled {
329                (sel_disabled_style, sel_right_style, sel_highlight_style)
330            } else {
331                (sel_style, sel_right_style, sel_highlight_style)
332            }
333        } else {
334            if item.disabled {
335                (disabled_style, right_style, highlight_style)
336            } else {
337                (style, right_style, highlight_style)
338            }
339        };
340
341        let item_line = if let Some(highlight) = item.highlight.clone() {
342            Line::from_iter([
343                Span::from(&item.item[..highlight.start - 1]), // account for _
344                Span::from(&item.item[highlight.start..highlight.end]).style(highlight_style),
345                Span::from(&item.item[highlight.end..]),
346                if !item.right.is_empty() {
347                    Span::from(format!("({})", item.right)).style(right_style)
348                } else {
349                    Span::default()
350                },
351            ])
352        } else {
353            Line::from_iter([
354                Span::from(item.item.as_ref()),
355                if !item.right.is_empty() {
356                    Span::from(format!("({})", item.right)).style(right_style)
357                } else {
358                    Span::default()
359                },
360            ])
361        };
362        item_line.style(style).render(item_area, buf);
363
364        item_area.x += item_area.width + 1;
365    }
366}
367
368impl HasFocus for MenuLineState {
369    fn build(&self, builder: &mut FocusBuilder) {
370        builder.leaf_widget(self);
371    }
372
373    /// Focus flag.
374    fn focus(&self) -> FocusFlag {
375        self.focus.clone()
376    }
377
378    /// Focus area.
379    fn area(&self) -> Rect {
380        self.area
381    }
382}
383
384#[allow(clippy::len_without_is_empty)]
385impl MenuLineState {
386    pub fn new() -> Self {
387        Self::default()
388    }
389
390    /// New with a focus name.
391    pub fn named(name: &str) -> Self {
392        Self {
393            focus: FocusFlag::named(name),
394            ..Default::default()
395        }
396    }
397
398    /// Number of items.
399    #[inline]
400    pub fn len(&self) -> usize {
401        self.item_areas.len()
402    }
403
404    /// Any items.
405    pub fn is_empty(&self) -> bool {
406        self.item_areas.is_empty()
407    }
408
409    /// Select
410    #[inline]
411    pub fn select(&mut self, select: Option<usize>) -> bool {
412        let old = self.selected;
413        self.selected = select;
414        old != self.selected
415    }
416
417    /// Selected index
418    #[inline]
419    pub fn selected(&self) -> Option<usize> {
420        self.selected
421    }
422
423    /// Previous item.
424    #[inline]
425    pub fn prev_item(&mut self) -> bool {
426        let old = self.selected;
427
428        // before first render or no items:
429        if self.disabled.is_empty() {
430            return false;
431        }
432
433        self.selected = if let Some(start) = old {
434            let mut idx = start;
435            loop {
436                if idx == 0 {
437                    idx = start;
438                    break;
439                }
440                idx -= 1;
441
442                if self.disabled.get(idx) == Some(&false) {
443                    break;
444                }
445            }
446
447            Some(idx)
448        } else if !self.is_empty() {
449            Some(self.len().saturating_sub(1))
450        } else {
451            None
452        };
453
454        old != self.selected
455    }
456
457    /// Next item.
458    #[inline]
459    pub fn next_item(&mut self) -> bool {
460        let old = self.selected;
461
462        // before first render or no items:
463        if self.disabled.is_empty() {
464            return false;
465        }
466
467        self.selected = if let Some(start) = old {
468            let mut idx = start;
469            loop {
470                if idx + 1 == self.len() {
471                    idx = start;
472                    break;
473                }
474                idx += 1;
475
476                if self.disabled.get(idx) == Some(&false) {
477                    break;
478                }
479            }
480            Some(idx)
481        } else if !self.is_empty() {
482            Some(0)
483        } else {
484            None
485        };
486
487        old != self.selected
488    }
489
490    /// Select by hotkey
491    #[inline]
492    pub fn navigate(&mut self, c: char) -> MenuOutcome {
493        // before first render or no items:
494        if self.disabled.is_empty() {
495            return MenuOutcome::Continue;
496        }
497
498        let c = c.to_ascii_lowercase();
499        for (i, cc) in self.navchar.iter().enumerate() {
500            #[allow(clippy::collapsible_if)]
501            if *cc == Some(c) {
502                if self.disabled.get(i) == Some(&false) {
503                    if self.selected == Some(i) {
504                        return MenuOutcome::Activated(i);
505                    } else {
506                        self.selected = Some(i);
507                        return MenuOutcome::Selected(i);
508                    }
509                }
510            }
511        }
512
513        MenuOutcome::Continue
514    }
515
516    /// Select item at position.
517    /// Only reports a change if the selection actually changed.
518    /// Reports no change before the first render and if no item was hit.
519    #[inline]
520    #[allow(clippy::collapsible_if)]
521    pub fn select_at(&mut self, pos: (u16, u16)) -> bool {
522        let old_selected = self.selected;
523
524        // before first render or no items:
525        if self.disabled.is_empty() {
526            return false;
527        }
528
529        if let Some(idx) = self.mouse.item_at(&self.item_areas, pos.0, pos.1) {
530            if self.disabled.get(idx) == Some(&false) {
531                self.selected = Some(idx);
532            }
533        }
534
535        self.selected != old_selected
536    }
537
538    /// Select item at position.
539    /// Reports a change even if the same menu item has been selected.
540    /// Reports no change before the first render and if no item was hit.
541    #[inline]
542    #[allow(clippy::collapsible_if)]
543    pub fn select_at_always(&mut self, pos: (u16, u16)) -> bool {
544        // before first render or no items:
545        if self.disabled.is_empty() {
546            return false;
547        }
548
549        if let Some(idx) = self.mouse.item_at(&self.item_areas, pos.0, pos.1) {
550            if self.disabled.get(idx) == Some(&false) {
551                self.selected = Some(idx);
552                return true;
553            }
554        }
555
556        false
557    }
558
559    /// Item at position.
560    #[inline]
561    pub fn item_at(&self, pos: (u16, u16)) -> Option<usize> {
562        self.mouse.item_at(&self.item_areas, pos.0, pos.1)
563    }
564}
565
566impl Clone for MenuLineState {
567    fn clone(&self) -> Self {
568        Self {
569            area: self.area,
570            item_areas: self.item_areas.clone(),
571            navchar: self.navchar.clone(),
572            disabled: self.disabled.clone(),
573            selected: self.selected,
574            focus: FocusFlag::named(self.focus.name()),
575            mouse: Default::default(),
576            non_exhaustive: NonExhaustive,
577        }
578    }
579}
580
581impl Default for MenuLineState {
582    fn default() -> Self {
583        Self {
584            area: Default::default(),
585            item_areas: vec![],
586            navchar: vec![],
587            disabled: vec![],
588            selected: None,
589            focus: Default::default(),
590            mouse: Default::default(),
591            non_exhaustive: NonExhaustive,
592        }
593    }
594}
595
596impl HandleEvent<crossterm::event::Event, Regular, MenuOutcome> for MenuLineState {
597    #[allow(clippy::redundant_closure)]
598    fn handle(&mut self, event: &crossterm::event::Event, _: Regular) -> MenuOutcome {
599        let res = if self.is_focused() {
600            match event {
601                ct_event!(key press ' ') => {
602                    self
603                        .selected//
604                        .map_or(MenuOutcome::Continue, |v| MenuOutcome::Selected(v))
605                }
606                ct_event!(key press ANY-c) => {
607                    self.navigate(*c) //
608                }
609                ct_event!(keycode press Left) => {
610                    if self.prev_item() {
611                        if let Some(selected) = self.selected {
612                            MenuOutcome::Selected(selected)
613                        } else {
614                            MenuOutcome::Changed
615                        }
616                    } else {
617                        MenuOutcome::Continue
618                    }
619                }
620                ct_event!(keycode press Right) => {
621                    if self.next_item() {
622                        if let Some(selected) = self.selected {
623                            MenuOutcome::Selected(selected)
624                        } else {
625                            MenuOutcome::Changed
626                        }
627                    } else {
628                        MenuOutcome::Continue
629                    }
630                }
631                ct_event!(keycode press Home) => {
632                    if self.select(Some(0)) {
633                        if let Some(selected) = self.selected {
634                            MenuOutcome::Selected(selected)
635                        } else {
636                            MenuOutcome::Changed
637                        }
638                    } else {
639                        MenuOutcome::Continue
640                    }
641                }
642                ct_event!(keycode press End) => {
643                    if self.select(Some(self.len().saturating_sub(1))) {
644                        if let Some(selected) = self.selected {
645                            MenuOutcome::Selected(selected)
646                        } else {
647                            MenuOutcome::Changed
648                        }
649                    } else {
650                        MenuOutcome::Continue
651                    }
652                }
653                ct_event!(keycode press Enter) => {
654                    if let Some(select) = self.selected {
655                        MenuOutcome::Activated(select)
656                    } else {
657                        MenuOutcome::Continue
658                    }
659                }
660                _ => MenuOutcome::Continue,
661            }
662        } else {
663            MenuOutcome::Continue
664        };
665
666        if res == MenuOutcome::Continue {
667            self.handle(event, MouseOnly)
668        } else {
669            res
670        }
671    }
672}
673
674impl HandleEvent<crossterm::event::Event, MouseOnly, MenuOutcome> for MenuLineState {
675    fn handle(&mut self, event: &crossterm::event::Event, _: MouseOnly) -> MenuOutcome {
676        match event {
677            ct_event!(mouse any for m) if self.mouse.doubleclick(self.area, m) => {
678                let idx = self.item_at(self.mouse.pos_of(m));
679                if self.selected() == idx {
680                    match self.selected {
681                        Some(a) => MenuOutcome::Activated(a),
682                        None => MenuOutcome::Continue,
683                    }
684                } else {
685                    MenuOutcome::Continue
686                }
687            }
688            ct_event!(mouse any for m) if self.mouse.drag(self.area, m) => {
689                let old = self.selected;
690                if self.select_at(self.mouse.pos_of(m)) {
691                    if old != self.selected {
692                        MenuOutcome::Selected(self.selected().expect("selected"))
693                    } else {
694                        MenuOutcome::Unchanged
695                    }
696                } else {
697                    MenuOutcome::Continue
698                }
699            }
700            ct_event!(mouse down Left for col, row) if self.area.contains((*col, *row).into()) => {
701                if self.select_at_always((*col, *row)) {
702                    MenuOutcome::Selected(self.selected().expect("selected"))
703                } else {
704                    MenuOutcome::Continue
705                }
706            }
707            _ => MenuOutcome::Continue,
708        }
709    }
710}
711
712/// Handle all events.
713/// Key events are only processed if focus is true.
714/// Mouse events are processed if they are in range.
715pub fn handle_events(
716    state: &mut MenuLineState,
717    focus: bool,
718    event: &crossterm::event::Event,
719) -> MenuOutcome {
720    state.focus.set(focus);
721    state.handle(event, Regular)
722}
723
724/// Handle only mouse-events.
725pub fn handle_mouse_events(
726    state: &mut MenuLineState,
727    event: &crossterm::event::Event,
728) -> MenuOutcome {
729    state.handle(event, MouseOnly)
730}