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