rat_menu/
menuline.rs

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