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