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 + Focus
214    #[inline]
215    pub fn focus_style(mut self, style: Style) -> Self {
216        self.focus_style = Some(style);
217        self
218    }
219
220    /// Selection + Focus
221    #[inline]
222    pub fn focus_style_opt(mut self, style: Option<Style>) -> Self {
223        self.focus_style = style;
224        self
225    }
226}
227
228impl<'a> StatefulWidget for &MenuLine<'a> {
229    type State = MenuLineState;
230
231    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
232        render_ref(self, area, buf, state);
233    }
234}
235
236impl StatefulWidget for MenuLine<'_> {
237    type State = MenuLineState;
238
239    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
240        render_ref(&self, area, buf, state);
241    }
242}
243
244fn render_ref(widget: &MenuLine<'_>, area: Rect, buf: &mut Buffer, state: &mut MenuLineState) {
245    state.area = area;
246    state.item_areas.clear();
247
248    if widget.menu.items.is_empty() {
249        state.selected = None;
250    } else if state.selected.is_none() {
251        state.selected = Some(0);
252    }
253
254    state.navchar = widget
255        .menu
256        .items
257        .iter()
258        .map(|v| v.navchar.map(|w| w.to_ascii_lowercase()))
259        .collect();
260    state.disabled = widget.menu.items.iter().map(|v| v.disabled).collect();
261
262    let style = widget.style;
263    let right_style = style.patch(widget.right_style.unwrap_or_default());
264    let highlight_style = style.patch(widget.highlight_style.unwrap_or(Style::new().underlined()));
265    let disabled_style = style.patch(widget.disabled_style.unwrap_or_default());
266
267    let (sel_style, sel_right_style, sel_highlight_style, sel_disabled_style) =
268        if state.is_focused() {
269            let focus_style = widget.focus_style.unwrap_or(revert_style(style));
270            (
271                focus_style,
272                focus_style.patch(right_style),
273                focus_style,
274                focus_style.patch(widget.disabled_style.unwrap_or_default()),
275            )
276        } else {
277            (
278                style, //
279                right_style,
280                highlight_style,
281                disabled_style,
282            )
283        };
284
285    let title_style = if let Some(title_style) = widget.title_style {
286        title_style
287    } else {
288        style.underlined()
289    };
290
291    buf.set_style(area, style);
292
293    let mut item_area = Rect::new(area.x, area.y, 0, 1);
294
295    if widget.title.width() > 0 {
296        item_area.width = widget.title.width() as u16;
297
298        buf.set_style(item_area, title_style);
299        widget.title.clone().render(item_area, buf);
300
301        item_area.x += item_area.width + 1;
302    }
303
304    for (n, item) in widget.menu.items.iter().enumerate() {
305        item_area.width =
306            item.item_width() + item.right_width() + if item.right.is_empty() { 0 } else { 2 };
307        if item_area.right() >= area.right() {
308            item_area = item_area.clamp(area);
309        }
310        state.item_areas.push(item_area);
311
312        #[allow(clippy::collapsible_else_if)]
313        let (style, right_style, highlight_style) = if state.selected == Some(n) {
314            if item.disabled {
315                (sel_disabled_style, sel_right_style, sel_highlight_style)
316            } else {
317                (sel_style, sel_right_style, sel_highlight_style)
318            }
319        } else {
320            if item.disabled {
321                (disabled_style, right_style, highlight_style)
322            } else {
323                (style, right_style, highlight_style)
324            }
325        };
326
327        let item_line = if let Some(highlight) = item.highlight.clone() {
328            Line::from_iter([
329                Span::from(&item.item[..highlight.start - 1]), // account for _
330                Span::from(&item.item[highlight.start..highlight.end]).style(highlight_style),
331                Span::from(&item.item[highlight.end..]),
332                if !item.right.is_empty() {
333                    Span::from(format!("({})", item.right)).style(right_style)
334                } else {
335                    Span::default()
336                },
337            ])
338        } else {
339            Line::from_iter([
340                Span::from(item.item.as_ref()),
341                if !item.right.is_empty() {
342                    Span::from(format!("({})", item.right)).style(right_style)
343                } else {
344                    Span::default()
345                },
346            ])
347        };
348        item_line.style(style).render(item_area, buf);
349
350        item_area.x += item_area.width + 1;
351    }
352}
353
354impl HasFocus for MenuLineState {
355    fn build(&self, builder: &mut FocusBuilder) {
356        builder.leaf_widget(self);
357    }
358
359    /// Focus flag.
360    fn focus(&self) -> FocusFlag {
361        self.focus.clone()
362    }
363
364    /// Focus area.
365    fn area(&self) -> Rect {
366        self.area
367    }
368}
369
370#[allow(clippy::len_without_is_empty)]
371impl MenuLineState {
372    pub fn new() -> Self {
373        Self::default()
374    }
375
376    /// New with a focus name.
377    pub fn named(name: &str) -> Self {
378        Self {
379            focus: FocusFlag::named(name),
380            ..Default::default()
381        }
382    }
383
384    /// Number of items.
385    #[inline]
386    pub fn len(&self) -> usize {
387        self.item_areas.len()
388    }
389
390    /// Any items.
391    pub fn is_empty(&self) -> bool {
392        self.item_areas.is_empty()
393    }
394
395    /// Select
396    #[inline]
397    pub fn select(&mut self, select: Option<usize>) -> bool {
398        let old = self.selected;
399        self.selected = select;
400        old != self.selected
401    }
402
403    /// Selected index
404    #[inline]
405    pub fn selected(&self) -> Option<usize> {
406        self.selected
407    }
408
409    /// Previous item.
410    #[inline]
411    pub fn prev_item(&mut self) -> bool {
412        let old = self.selected;
413
414        // before first render or no items:
415        if self.disabled.is_empty() {
416            return false;
417        }
418
419        self.selected = if let Some(start) = old {
420            let mut idx = start;
421            loop {
422                if idx == 0 {
423                    idx = start;
424                    break;
425                }
426                idx -= 1;
427
428                if self.disabled.get(idx) == Some(&false) {
429                    break;
430                }
431            }
432
433            Some(idx)
434        } else if !self.is_empty() {
435            Some(self.len().saturating_sub(1))
436        } else {
437            None
438        };
439
440        old != self.selected
441    }
442
443    /// Next item.
444    #[inline]
445    pub fn next_item(&mut self) -> bool {
446        let old = self.selected;
447
448        // before first render or no items:
449        if self.disabled.is_empty() {
450            return false;
451        }
452
453        self.selected = if let Some(start) = old {
454            let mut idx = start;
455            loop {
456                if idx + 1 == self.len() {
457                    idx = start;
458                    break;
459                }
460                idx += 1;
461
462                if self.disabled.get(idx) == Some(&false) {
463                    break;
464                }
465            }
466            Some(idx)
467        } else if !self.is_empty() {
468            Some(0)
469        } else {
470            None
471        };
472
473        old != self.selected
474    }
475
476    /// Select by hotkey
477    #[inline]
478    pub fn navigate(&mut self, c: char) -> MenuOutcome {
479        // before first render or no items:
480        if self.disabled.is_empty() {
481            return MenuOutcome::Continue;
482        }
483
484        let c = c.to_ascii_lowercase();
485        for (i, cc) in self.navchar.iter().enumerate() {
486            #[allow(clippy::collapsible_if)]
487            if *cc == Some(c) {
488                if self.disabled.get(i) == Some(&false) {
489                    if self.selected == Some(i) {
490                        return MenuOutcome::Activated(i);
491                    } else {
492                        self.selected = Some(i);
493                        return MenuOutcome::Selected(i);
494                    }
495                }
496            }
497        }
498
499        MenuOutcome::Continue
500    }
501
502    /// Select item at position.
503    /// Only reports a change if the selection actually changed.
504    /// Reports no change before the first render and if no item was hit.
505    #[inline]
506    #[allow(clippy::collapsible_if)]
507    pub fn select_at(&mut self, pos: (u16, u16)) -> bool {
508        let old_selected = self.selected;
509
510        // before first render or no items:
511        if self.disabled.is_empty() {
512            return false;
513        }
514
515        if let Some(idx) = self.mouse.item_at(&self.item_areas, pos.0, pos.1) {
516            if self.disabled.get(idx) == Some(&false) {
517                self.selected = Some(idx);
518            }
519        }
520
521        self.selected != old_selected
522    }
523
524    /// Select item at position.
525    /// Reports a change even if the same menu item has been selected.
526    /// Reports no change before the first render and if no item was hit.
527    #[inline]
528    #[allow(clippy::collapsible_if)]
529    pub fn select_at_always(&mut self, pos: (u16, u16)) -> bool {
530        // before first render or no items:
531        if self.disabled.is_empty() {
532            return false;
533        }
534
535        if let Some(idx) = self.mouse.item_at(&self.item_areas, pos.0, pos.1) {
536            if self.disabled.get(idx) == Some(&false) {
537                self.selected = Some(idx);
538                return true;
539            }
540        }
541
542        false
543    }
544
545    /// Item at position.
546    #[inline]
547    pub fn item_at(&self, pos: (u16, u16)) -> Option<usize> {
548        self.mouse.item_at(&self.item_areas, pos.0, pos.1)
549    }
550}
551
552impl Clone for MenuLineState {
553    fn clone(&self) -> Self {
554        Self {
555            area: self.area,
556            item_areas: self.item_areas.clone(),
557            navchar: self.navchar.clone(),
558            disabled: self.disabled.clone(),
559            selected: self.selected,
560            focus: FocusFlag::named(self.focus.name()),
561            mouse: Default::default(),
562            non_exhaustive: NonExhaustive,
563        }
564    }
565}
566
567impl Default for MenuLineState {
568    fn default() -> Self {
569        Self {
570            area: Default::default(),
571            item_areas: vec![],
572            navchar: vec![],
573            disabled: vec![],
574            selected: None,
575            focus: Default::default(),
576            mouse: Default::default(),
577            non_exhaustive: NonExhaustive,
578        }
579    }
580}
581
582impl HandleEvent<crossterm::event::Event, Regular, MenuOutcome> for MenuLineState {
583    #[allow(clippy::redundant_closure)]
584    fn handle(&mut self, event: &crossterm::event::Event, _: Regular) -> MenuOutcome {
585        let res = if self.is_focused() {
586            match event {
587                ct_event!(key press ' ') => {
588                    self
589                        .selected//
590                        .map_or(MenuOutcome::Continue, |v| MenuOutcome::Selected(v))
591                }
592                ct_event!(key press ANY-c) => {
593                    self.navigate(*c) //
594                }
595                ct_event!(keycode press Left) => {
596                    if self.prev_item() {
597                        if let Some(selected) = self.selected {
598                            MenuOutcome::Selected(selected)
599                        } else {
600                            MenuOutcome::Changed
601                        }
602                    } else {
603                        MenuOutcome::Continue
604                    }
605                }
606                ct_event!(keycode press Right) => {
607                    if self.next_item() {
608                        if let Some(selected) = self.selected {
609                            MenuOutcome::Selected(selected)
610                        } else {
611                            MenuOutcome::Changed
612                        }
613                    } else {
614                        MenuOutcome::Continue
615                    }
616                }
617                ct_event!(keycode press Home) => {
618                    if self.select(Some(0)) {
619                        if let Some(selected) = self.selected {
620                            MenuOutcome::Selected(selected)
621                        } else {
622                            MenuOutcome::Changed
623                        }
624                    } else {
625                        MenuOutcome::Continue
626                    }
627                }
628                ct_event!(keycode press End) => {
629                    if self.select(Some(self.len().saturating_sub(1))) {
630                        if let Some(selected) = self.selected {
631                            MenuOutcome::Selected(selected)
632                        } else {
633                            MenuOutcome::Changed
634                        }
635                    } else {
636                        MenuOutcome::Continue
637                    }
638                }
639                ct_event!(keycode press Enter) => {
640                    if let Some(select) = self.selected {
641                        MenuOutcome::Activated(select)
642                    } else {
643                        MenuOutcome::Continue
644                    }
645                }
646                _ => MenuOutcome::Continue,
647            }
648        } else {
649            MenuOutcome::Continue
650        };
651
652        if res == MenuOutcome::Continue {
653            self.handle(event, MouseOnly)
654        } else {
655            res
656        }
657    }
658}
659
660impl HandleEvent<crossterm::event::Event, MouseOnly, MenuOutcome> for MenuLineState {
661    fn handle(&mut self, event: &crossterm::event::Event, _: MouseOnly) -> MenuOutcome {
662        match event {
663            ct_event!(mouse any for m) if self.mouse.doubleclick(self.area, m) => {
664                let idx = self.item_at(self.mouse.pos_of(m));
665                if self.selected() == idx {
666                    match self.selected {
667                        Some(a) => MenuOutcome::Activated(a),
668                        None => MenuOutcome::Continue,
669                    }
670                } else {
671                    MenuOutcome::Continue
672                }
673            }
674            ct_event!(mouse any for m) if self.mouse.drag(self.area, m) => {
675                let old = self.selected;
676                if self.select_at(self.mouse.pos_of(m)) {
677                    if old != self.selected {
678                        MenuOutcome::Selected(self.selected().expect("selected"))
679                    } else {
680                        MenuOutcome::Unchanged
681                    }
682                } else {
683                    MenuOutcome::Continue
684                }
685            }
686            ct_event!(mouse down Left for col, row) if self.area.contains((*col, *row).into()) => {
687                if self.select_at_always((*col, *row)) {
688                    MenuOutcome::Selected(self.selected().expect("selected"))
689                } else {
690                    MenuOutcome::Continue
691                }
692            }
693            _ => MenuOutcome::Continue,
694        }
695    }
696}
697
698/// Handle all events.
699/// Key events are only processed if focus is true.
700/// Mouse events are processed if they are in range.
701pub fn handle_events(
702    state: &mut MenuLineState,
703    focus: bool,
704    event: &crossterm::event::Event,
705) -> MenuOutcome {
706    state.focus.set(focus);
707    state.handle(event, Regular)
708}
709
710/// Handle only mouse-events.
711pub fn handle_mouse_events(
712    state: &mut MenuLineState,
713    event: &crossterm::event::Event,
714) -> MenuOutcome {
715    state.handle(event, MouseOnly)
716}