rat_menu/
popup_menu.rs

1//!
2//! A popup-menu.
3//!
4//! It diverges from other widgets as it doesn't draw
5//! *inside* the given area but aims to stay *outside* of it.
6//!
7//! You can give a [PopupConstraint] where the popup-menu should appear
8//! relative to the given area.
9//!
10//! If you want it to appear at a mouse-click position, use a
11//! `Rect::new(mouse_x, mouse_y, 0,0)` area.
12//! If you want it to appear next to a given widget, use
13//! the widgets drawing area.
14//!
15//! If no special boundary is set, the widget tries to stay
16//! inside the `buffer.area`.
17
18use crate::_private::NonExhaustive;
19use crate::event::MenuOutcome;
20use crate::util::revert_style;
21use crate::{MenuBuilder, MenuItem, MenuStyle, Separator};
22use rat_event::util::{mouse_trap, MouseFlags};
23use rat_event::{ct_event, ConsumedEvent, HandleEvent, MouseOnly, Popup};
24use rat_popup::event::PopupOutcome;
25pub use rat_popup::PopupConstraint;
26use rat_popup::{PopupCore, PopupCoreState};
27use ratatui::buffer::Buffer;
28use ratatui::layout::{Rect, Size};
29use ratatui::style::{Style, Stylize};
30use ratatui::text::{Line, Span};
31use ratatui::widgets::StatefulWidget;
32use ratatui::widgets::{Block, Padding, Widget};
33use std::cmp::max;
34use unicode_segmentation::UnicodeSegmentation;
35
36/// Popup menu.
37#[derive(Debug, Default, Clone)]
38pub struct PopupMenu<'a> {
39    pub(crate) menu: MenuBuilder<'a>,
40
41    width: Option<u16>,
42    popup: PopupCore<'a>,
43
44    style: Style,
45    highlight_style: Option<Style>,
46    disabled_style: Option<Style>,
47    right_style: Option<Style>,
48    focus_style: Option<Style>,
49}
50
51/// State & event handling.
52#[derive(Debug, Clone)]
53pub struct PopupMenuState {
54    /// Popup data.
55    pub popup: PopupCoreState,
56    /// Areas for each item.
57    /// __readonly__. renewed for each render.
58    pub item_areas: Vec<Rect>,
59    /// Area for the separator after each item.
60    /// The area has height 0 if there is no separator.
61    /// __readonly__. renewed for each render.
62    pub sep_areas: Vec<Rect>,
63    /// Letter navigation
64    /// __readonly__. renewed for each render.
65    pub navchar: Vec<Option<char>>,
66    /// Disabled menu-items.
67    pub disabled: Vec<bool>,
68
69    // TODO: breaking: remove Option
70    /// Selected item.
71    /// __read+write__
72    pub selected: Option<usize>,
73
74    /// Mouse flags
75    /// __used for mouse interaction__
76    pub mouse: MouseFlags,
77
78    pub non_exhaustive: NonExhaustive,
79}
80
81impl Default for PopupMenuState {
82    fn default() -> Self {
83        Self {
84            popup: Default::default(),
85            item_areas: vec![],
86            sep_areas: vec![],
87            navchar: vec![],
88            disabled: vec![],
89            selected: None,
90            mouse: Default::default(),
91            non_exhaustive: NonExhaustive,
92        }
93    }
94}
95
96impl PopupMenu<'_> {
97    fn size(&self) -> Size {
98        let width = if let Some(width) = self.width {
99            width
100        } else {
101            let text_width = self
102                .menu
103                .items
104                .iter()
105                .map(|v| (v.item_width() * 3) / 2 + v.right_width())
106                .max();
107            text_width.unwrap_or(10)
108        };
109        let height = self.menu.items.iter().map(MenuItem::height).sum::<u16>();
110
111        let block = self.popup.get_block_size();
112
113        #[allow(clippy::if_same_then_else)]
114        let vertical_padding = if block.height == 0 { 2 } else { 0 };
115        let horizontal_padding = 2;
116
117        Size::new(
118            width + horizontal_padding + block.width,
119            height + vertical_padding + block.height,
120        )
121    }
122
123    fn layout(&self, area: Rect, inner: Rect, state: &mut PopupMenuState) {
124        let block = Size::new(area.width - inner.width, area.height - inner.height);
125
126        // add text padding.
127        #[allow(clippy::if_same_then_else)]
128        let vert_offset = if block.height == 0 { 1 } else { 0 };
129        let horiz_offset = 1;
130        let horiz_offset_sep = 0;
131
132        state.item_areas.clear();
133        state.sep_areas.clear();
134
135        let mut row = 0;
136
137        for item in &self.menu.items {
138            state.item_areas.push(Rect::new(
139                inner.x + horiz_offset,
140                inner.y + row + vert_offset,
141                inner.width.saturating_sub(2 * horiz_offset),
142                1,
143            ));
144            state.sep_areas.push(Rect::new(
145                inner.x + horiz_offset_sep,
146                inner.y + row + 1 + vert_offset,
147                inner.width.saturating_sub(2 * horiz_offset_sep),
148                if item.separator.is_some() { 1 } else { 0 },
149            ));
150
151            row += item.height();
152        }
153    }
154}
155
156impl<'a> PopupMenu<'a> {
157    /// New, empty.
158    pub fn new() -> Self {
159        Default::default()
160    }
161
162    /// Add an item.
163    pub fn item(mut self, item: MenuItem<'a>) -> Self {
164        self.menu.item(item);
165        self
166    }
167
168    /// Parse the text.
169    ///
170    /// __See__
171    ///
172    /// [MenuItem::new_parsed]
173    pub fn item_parsed(mut self, text: &'a str) -> Self {
174        self.menu.item_parsed(text);
175        self
176    }
177
178    /// Add a text-item.
179    pub fn item_str(mut self, txt: &'a str) -> Self {
180        self.menu.item_str(txt);
181        self
182    }
183
184    /// Add an owned text as item.
185    pub fn item_string(mut self, txt: String) -> Self {
186        self.menu.item_string(txt);
187        self
188    }
189
190    /// Sets the separator for the last item added.
191    /// If there is none adds this as an empty menu-item.
192    pub fn separator(mut self, separator: Separator) -> Self {
193        self.menu.separator(separator);
194        self
195    }
196
197    /// Fixed width for the menu.
198    /// If not set it uses 1.5 times the length of the longest item.
199    pub fn width(mut self, width: u16) -> Self {
200        self.width = Some(width);
201        self
202    }
203
204    /// Fixed width for the menu.
205    /// If not set it uses 1.5 times the length of the longest item.
206    pub fn width_opt(mut self, width: Option<u16>) -> Self {
207        self.width = width;
208        self
209    }
210
211    /// Set relative placement.
212    pub fn constraint(mut self, placement: PopupConstraint) -> Self {
213        self.popup = self.popup.constraint(placement);
214        self
215    }
216
217    /// Adds an extra offset.
218    pub fn offset(mut self, offset: (i16, i16)) -> Self {
219        self.popup = self.popup.offset(offset);
220        self
221    }
222
223    /// Adds an extra x offset.
224    pub fn x_offset(mut self, offset: i16) -> Self {
225        self.popup = self.popup.x_offset(offset);
226        self
227    }
228
229    /// Adds an extra y offset.
230    pub fn y_offset(mut self, offset: i16) -> Self {
231        self.popup = self.popup.y_offset(offset);
232        self
233    }
234
235    /// Set outer bounds for the popup-menu.
236    /// If not set, the [Buffer::area] is used as outer bounds.
237    pub fn boundary(mut self, boundary: Rect) -> Self {
238        self.popup = self.popup.boundary(boundary);
239        self
240    }
241
242    /// Set a style-set.
243    pub fn styles(mut self, styles: MenuStyle) -> Self {
244        self.style = styles.style;
245
246        self.popup = self.popup.styles(styles.popup);
247        if styles.highlight.is_some() {
248            self.highlight_style = styles.highlight;
249        }
250        if styles.disabled.is_some() {
251            self.disabled_style = styles.disabled;
252        }
253        if styles.right.is_some() {
254            self.right_style = styles.right;
255        }
256        if styles.focus.is_some() {
257            self.focus_style = styles.focus;
258        }
259        self
260    }
261
262    /// Base style.
263    pub fn style(mut self, style: Style) -> Self {
264        self.popup = self.popup.style(style);
265        self.style = style;
266        self
267    }
268
269    /// Highlight style.
270    pub fn highlight_style(mut self, style: Style) -> Self {
271        self.highlight_style = Some(style);
272        self
273    }
274
275    /// Highlight style.
276    pub fn highlight_style_opt(mut self, style: Option<Style>) -> Self {
277        self.highlight_style = style;
278        self
279    }
280
281    /// Disabled item style.
282    #[inline]
283    pub fn disabled_style(mut self, style: Style) -> Self {
284        self.disabled_style = Some(style);
285        self
286    }
287
288    /// Disabled item style.
289    #[inline]
290    pub fn disabled_style_opt(mut self, style: Option<Style>) -> Self {
291        self.disabled_style = style;
292        self
293    }
294
295    /// Style for the hotkey.
296    #[inline]
297    pub fn right_style(mut self, style: Style) -> Self {
298        self.right_style = Some(style);
299        self
300    }
301
302    /// Style for the hotkey.
303    #[inline]
304    pub fn right_style_opt(mut self, style: Option<Style>) -> Self {
305        self.right_style = style;
306        self
307    }
308
309    /// Focus/Selection style.
310    pub fn focus_style(mut self, style: Style) -> Self {
311        self.focus_style = Some(style);
312        self
313    }
314
315    /// Focus/Selection style.
316    pub fn focus_style_opt(mut self, style: Option<Style>) -> Self {
317        self.focus_style = style;
318        self
319    }
320
321    /// Block for borders.
322    pub fn block(mut self, block: Block<'a>) -> Self {
323        self.popup = self.popup.block(block);
324        self
325    }
326
327    /// Block for borders.
328    pub fn block_opt(mut self, block: Option<Block<'a>>) -> Self {
329        self.popup = self.popup.block_opt(block);
330        self
331    }
332
333    /// Get the padding the block imposes as a Size.
334    pub fn get_block_size(&self) -> Size {
335        self.popup.get_block_size()
336    }
337
338    /// Get the padding the block imposes as a Size.
339    pub fn get_block_padding(&self) -> Padding {
340        self.popup.get_block_padding()
341    }
342}
343
344impl<'a> StatefulWidget for &PopupMenu<'a> {
345    type State = PopupMenuState;
346
347    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
348        render_popup_menu(self, area, buf, state);
349    }
350}
351
352impl StatefulWidget for PopupMenu<'_> {
353    type State = PopupMenuState;
354
355    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
356        render_popup_menu(&self, area, buf, state);
357    }
358}
359
360fn render_popup_menu(
361    widget: &PopupMenu<'_>,
362    _area: Rect,
363    buf: &mut Buffer,
364    state: &mut PopupMenuState,
365) {
366    if widget.menu.items.is_empty() {
367        state.selected = None;
368    } else if state.selected.is_none() {
369        state.selected = Some(0);
370    }
371
372    state.navchar = widget.menu.items.iter().map(|v| v.navchar).collect();
373    state.disabled = widget.menu.items.iter().map(|v| v.disabled).collect();
374
375    if !state.is_active() {
376        state.clear_areas();
377        return;
378    }
379
380    let size = widget.size();
381    let area = Rect::new(0, 0, size.width, size.height);
382
383    (&widget.popup).render(area, buf, &mut state.popup);
384    widget.layout(state.popup.area, state.popup.widget_area, state);
385    render_items(widget, buf, state);
386}
387
388fn render_items(widget: &PopupMenu<'_>, buf: &mut Buffer, state: &mut PopupMenuState) {
389    let style = widget.style;
390    let select_style = if let Some(focus) = widget.focus_style {
391        focus
392    } else {
393        revert_style(style)
394    };
395    let highlight_style = if let Some(highlight_style) = widget.highlight_style {
396        highlight_style
397    } else {
398        Style::new().underlined()
399    };
400    let right_style = if let Some(right_style) = widget.right_style {
401        right_style
402    } else {
403        Style::default().italic()
404    };
405    let disabled_style = if let Some(disabled_style) = widget.disabled_style {
406        disabled_style
407    } else {
408        style
409    };
410
411    for (n, item) in widget.menu.items.iter().enumerate() {
412        let mut item_area = state.item_areas[n];
413
414        #[allow(clippy::collapsible_else_if)]
415        let (style, right_style) = if state.selected == Some(n) {
416            if item.disabled {
417                (
418                    style.patch(disabled_style),
419                    style.patch(disabled_style).patch(right_style),
420                )
421            } else {
422                (
423                    style.patch(select_style),
424                    style.patch(select_style).patch(right_style),
425                )
426            }
427        } else {
428            if item.disabled {
429                (
430                    style.patch(disabled_style),
431                    style.patch(disabled_style).patch(right_style),
432                )
433            } else {
434                (style, style.patch(right_style))
435            }
436        };
437
438        let item_line = if let Some(highlight) = item.highlight.clone() {
439            Line::from_iter([
440                Span::from(&item.item[..highlight.start - 1]), // account for _
441                Span::from(&item.item[highlight.start..highlight.end]).style(highlight_style),
442                Span::from(&item.item[highlight.end..]),
443            ])
444        } else {
445            Line::from(item.item.as_ref())
446        };
447        item_line.style(style).render(item_area, buf);
448
449        if !item.right.is_empty() {
450            let right_width = item.right.graphemes(true).count() as u16;
451            if right_width < item_area.width {
452                let delta = item_area.width.saturating_sub(right_width);
453                item_area.x += delta;
454                item_area.width -= delta;
455            }
456            Span::from(item.right.as_ref())
457                .style(right_style)
458                .render(item_area, buf);
459        }
460
461        if let Some(separator) = item.separator {
462            let sep_area = state.sep_areas[n];
463            let sym = match separator {
464                Separator::Empty => " ",
465                Separator::Plain => "\u{2500}",
466                Separator::Thick => "\u{2501}",
467                Separator::Double => "\u{2550}",
468                Separator::Dashed => "\u{2212}",
469                Separator::Dotted => "\u{2508}",
470            };
471            for x in 0..sep_area.width {
472                if let Some(cell) = buf.cell_mut((sep_area.x + x, sep_area.y)) {
473                    cell.set_symbol(sym);
474                }
475            }
476        }
477    }
478}
479
480impl PopupMenuState {
481    /// New
482    #[inline]
483    pub fn new() -> Self {
484        Default::default()
485    }
486
487    /// New state with a focus name.
488    #[deprecated(since = "1.0.5", note = "no longer useful")]
489    pub fn named(_: &'static str) -> Self {
490        Self {
491            popup: PopupCoreState::new(),
492            ..Default::default()
493        }
494    }
495
496    /// Set the z-index for the popup-menu.
497    pub fn set_popup_z(&mut self, z: u16) {
498        self.popup.area_z = z;
499    }
500
501    /// The z-index for the popup-menu.
502    pub fn popup_z(&self) -> u16 {
503        self.popup.area_z
504    }
505
506    /// Show the popup.
507    pub fn flip_active(&mut self) {
508        self.popup.flip_active();
509    }
510
511    /// Show the popup.
512    pub fn is_active(&self) -> bool {
513        self.popup.is_active()
514    }
515
516    /// Show the popup.
517    pub fn set_active(&mut self, active: bool) {
518        self.popup.set_active(active);
519        if !active {
520            self.clear_areas();
521        }
522    }
523
524    /// Clear the areas.
525    pub fn clear_areas(&mut self) {
526        self.popup.clear_areas();
527        self.sep_areas.clear();
528        self.navchar.clear();
529        self.item_areas.clear();
530        self.disabled.clear();
531    }
532
533    /// Number of items.
534    #[inline]
535    pub fn len(&self) -> usize {
536        self.item_areas.len()
537    }
538
539    /// Any items.
540    #[inline]
541    pub fn is_empty(&self) -> bool {
542        self.item_areas.is_empty()
543    }
544
545    /// Selected item.
546    #[inline]
547    pub fn select(&mut self, select: Option<usize>) -> bool {
548        let old = self.selected;
549        self.selected = select;
550        old != self.selected
551    }
552
553    /// Selected item.
554    #[inline]
555    pub fn selected(&self) -> Option<usize> {
556        self.selected
557    }
558
559    /// Select the previous item.
560    #[inline]
561    pub fn prev_item(&mut self) -> bool {
562        let old = self.selected;
563
564        // before first render or no items:
565        if self.disabled.is_empty() {
566            return false;
567        }
568
569        self.selected = if let Some(start) = old {
570            let mut idx = start;
571            loop {
572                if idx == 0 {
573                    idx = start;
574                    break;
575                }
576                idx -= 1;
577
578                if self.disabled.get(idx) == Some(&false) {
579                    break;
580                }
581            }
582            Some(idx)
583        } else if !self.is_empty() {
584            Some(self.len() - 1)
585        } else {
586            None
587        };
588
589        old != self.selected
590    }
591
592    /// Select the next item.
593    #[inline]
594    pub fn next_item(&mut self) -> bool {
595        let old = self.selected;
596
597        // before first render or no items:
598        if self.disabled.is_empty() {
599            return false;
600        }
601
602        self.selected = if let Some(start) = old {
603            let mut idx = start;
604            loop {
605                if idx + 1 == self.len() {
606                    idx = start;
607                    break;
608                }
609                idx += 1;
610
611                if self.disabled.get(idx) == Some(&false) {
612                    break;
613                }
614            }
615            Some(idx)
616        } else if !self.is_empty() {
617            Some(0)
618        } else {
619            None
620        };
621
622        old != self.selected
623    }
624
625    /// Select by navigation key.
626    #[inline]
627    pub fn navigate(&mut self, c: char) -> MenuOutcome {
628        // before first render or no items:
629        if self.disabled.is_empty() {
630            return MenuOutcome::Continue;
631        }
632
633        let c = c.to_ascii_lowercase();
634        for (i, cc) in self.navchar.iter().enumerate() {
635            #[allow(clippy::collapsible_if)]
636            if *cc == Some(c) {
637                if self.disabled.get(i) == Some(&false) {
638                    if self.selected == Some(i) {
639                        return MenuOutcome::Activated(i);
640                    } else {
641                        self.selected = Some(i);
642                        return MenuOutcome::Selected(i);
643                    }
644                }
645            }
646        }
647
648        MenuOutcome::Continue
649    }
650
651    /// Select item at position.
652    #[inline]
653    pub fn select_at(&mut self, pos: (u16, u16)) -> bool {
654        let old_selected = self.selected;
655
656        // before first render or no items:
657        if self.disabled.is_empty() {
658            return false;
659        }
660
661        if let Some(idx) = self.mouse.item_at(&self.item_areas, pos.0, pos.1) {
662            if !self.disabled[idx] {
663                self.selected = Some(idx);
664            }
665        }
666
667        self.selected != old_selected
668    }
669
670    /// Item at position.
671    #[inline]
672    pub fn item_at(&self, pos: (u16, u16)) -> Option<usize> {
673        self.mouse.item_at(&self.item_areas, pos.0, pos.1)
674    }
675}
676
677impl HandleEvent<crossterm::event::Event, Popup, MenuOutcome> for PopupMenuState {
678    fn handle(&mut self, event: &crossterm::event::Event, _qualifier: Popup) -> MenuOutcome {
679        let r0 = match self.popup.handle(event, Popup) {
680            PopupOutcome::Hide => MenuOutcome::Hide,
681            r => r.into(),
682        };
683
684        let r1 = if self.is_active() {
685            match event {
686                ct_event!(key press ANY-c) => {
687                    let r = self.navigate(*c);
688                    if matches!(r, MenuOutcome::Activated(_)) {
689                        self.set_active(false);
690                    }
691                    r
692                }
693                ct_event!(keycode press Up) => {
694                    if self.prev_item() {
695                        if let Some(selected) = self.selected {
696                            MenuOutcome::Selected(selected)
697                        } else {
698                            MenuOutcome::Changed
699                        }
700                    } else {
701                        MenuOutcome::Continue
702                    }
703                }
704                ct_event!(keycode press Down) => {
705                    if self.next_item() {
706                        if let Some(selected) = self.selected {
707                            MenuOutcome::Selected(selected)
708                        } else {
709                            MenuOutcome::Changed
710                        }
711                    } else {
712                        MenuOutcome::Continue
713                    }
714                }
715                ct_event!(keycode press Home) => {
716                    if self.select(Some(0)) {
717                        if let Some(selected) = self.selected {
718                            MenuOutcome::Selected(selected)
719                        } else {
720                            MenuOutcome::Changed
721                        }
722                    } else {
723                        MenuOutcome::Continue
724                    }
725                }
726                ct_event!(keycode press End) => {
727                    if self.select(Some(self.len().saturating_sub(1))) {
728                        if let Some(selected) = self.selected {
729                            MenuOutcome::Selected(selected)
730                        } else {
731                            MenuOutcome::Changed
732                        }
733                    } else {
734                        MenuOutcome::Continue
735                    }
736                }
737                ct_event!(keycode press Esc) => {
738                    self.set_active(false);
739                    MenuOutcome::Changed
740                }
741                ct_event!(keycode press Enter) => {
742                    if let Some(select) = self.selected {
743                        self.set_active(false);
744                        MenuOutcome::Activated(select)
745                    } else {
746                        MenuOutcome::Continue
747                    }
748                }
749
750                _ => MenuOutcome::Continue,
751            }
752        } else {
753            MenuOutcome::Continue
754        };
755
756        let r = max(r0, r1);
757
758        if !r.is_consumed() {
759            self.handle(event, MouseOnly)
760        } else {
761            r
762        }
763    }
764}
765
766impl HandleEvent<crossterm::event::Event, MouseOnly, MenuOutcome> for PopupMenuState {
767    fn handle(&mut self, event: &crossterm::event::Event, _: MouseOnly) -> MenuOutcome {
768        if self.is_active() {
769            let r = match event {
770                ct_event!(mouse moved for col, row)
771                    if self.popup.widget_area.contains((*col, *row).into()) =>
772                {
773                    if self.select_at((*col, *row)) {
774                        MenuOutcome::Selected(self.selected().expect("selection"))
775                    } else {
776                        MenuOutcome::Unchanged
777                    }
778                }
779                ct_event!(mouse down Left for col, row)
780                    if self.popup.widget_area.contains((*col, *row).into()) =>
781                {
782                    if self.item_at((*col, *row)).is_some() {
783                        self.set_active(false);
784                        MenuOutcome::Activated(self.selected().expect("selection"))
785                    } else {
786                        MenuOutcome::Unchanged
787                    }
788                }
789                _ => MenuOutcome::Continue,
790            };
791
792            r.or_else(|| mouse_trap(event, self.popup.area).into())
793        } else {
794            MenuOutcome::Continue
795        }
796    }
797}
798
799/// Handle all events.
800/// The assumption is, the popup-menu is focused or it is hidden.
801/// This state must be handled outside of this widget.
802pub fn handle_popup_events(
803    state: &mut PopupMenuState,
804    event: &crossterm::event::Event,
805) -> MenuOutcome {
806    state.handle(event, Popup)
807}
808
809/// Handle only mouse-events.
810pub fn handle_mouse_events(
811    state: &mut PopupMenuState,
812    event: &crossterm::event::Event,
813) -> MenuOutcome {
814    state.handle(event, MouseOnly)
815}