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