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