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