rat_menu/
menuline.rs

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