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    pub fn named(name: &'static str) -> Self {
489        Self {
490            popup: PopupCoreState::named(format!("{}.popup", name).to_string().leak()),
491            ..Default::default()
492        }
493    }
494
495    /// Set the z-index for the popup-menu.
496    pub fn set_popup_z(&mut self, z: u16) {
497        self.popup.area_z = z;
498    }
499
500    /// The z-index for the popup-menu.
501    pub fn popup_z(&self) -> u16 {
502        self.popup.area_z
503    }
504
505    /// Show the popup.
506    pub fn flip_active(&mut self) {
507        self.popup.flip_active();
508    }
509
510    /// Show the popup.
511    pub fn is_active(&self) -> bool {
512        self.popup.is_active()
513    }
514
515    /// Show the popup.
516    pub fn set_active(&mut self, active: bool) {
517        self.popup.set_active(active);
518        if !active {
519            self.clear_areas();
520        }
521    }
522
523    /// Clear the areas.
524    pub fn clear_areas(&mut self) {
525        self.popup.clear_areas();
526        self.sep_areas.clear();
527        self.navchar.clear();
528        self.item_areas.clear();
529        self.disabled.clear();
530    }
531
532    /// Number of items.
533    #[inline]
534    pub fn len(&self) -> usize {
535        self.item_areas.len()
536    }
537
538    /// Any items.
539    #[inline]
540    pub fn is_empty(&self) -> bool {
541        self.item_areas.is_empty()
542    }
543
544    /// Selected item.
545    #[inline]
546    pub fn select(&mut self, select: Option<usize>) -> bool {
547        let old = self.selected;
548        self.selected = select;
549        old != self.selected
550    }
551
552    /// Selected item.
553    #[inline]
554    pub fn selected(&self) -> Option<usize> {
555        self.selected
556    }
557
558    /// Select the previous item.
559    #[inline]
560    pub fn prev_item(&mut self) -> bool {
561        let old = self.selected;
562
563        // before first render or no items:
564        if self.disabled.is_empty() {
565            return false;
566        }
567
568        self.selected = if let Some(start) = old {
569            let mut idx = start;
570            loop {
571                if idx == 0 {
572                    idx = start;
573                    break;
574                }
575                idx -= 1;
576
577                if self.disabled.get(idx) == Some(&false) {
578                    break;
579                }
580            }
581            Some(idx)
582        } else if !self.is_empty() {
583            Some(self.len() - 1)
584        } else {
585            None
586        };
587
588        old != self.selected
589    }
590
591    /// Select the next item.
592    #[inline]
593    pub fn next_item(&mut self) -> bool {
594        let old = self.selected;
595
596        // before first render or no items:
597        if self.disabled.is_empty() {
598            return false;
599        }
600
601        self.selected = if let Some(start) = old {
602            let mut idx = start;
603            loop {
604                if idx + 1 == self.len() {
605                    idx = start;
606                    break;
607                }
608                idx += 1;
609
610                if self.disabled.get(idx) == Some(&false) {
611                    break;
612                }
613            }
614            Some(idx)
615        } else if !self.is_empty() {
616            Some(0)
617        } else {
618            None
619        };
620
621        old != self.selected
622    }
623
624    /// Select by navigation key.
625    #[inline]
626    pub fn navigate(&mut self, c: char) -> MenuOutcome {
627        // before first render or no items:
628        if self.disabled.is_empty() {
629            return MenuOutcome::Continue;
630        }
631
632        let c = c.to_ascii_lowercase();
633        for (i, cc) in self.navchar.iter().enumerate() {
634            #[allow(clippy::collapsible_if)]
635            if *cc == Some(c) {
636                if self.disabled.get(i) == Some(&false) {
637                    if self.selected == Some(i) {
638                        return MenuOutcome::Activated(i);
639                    } else {
640                        self.selected = Some(i);
641                        return MenuOutcome::Selected(i);
642                    }
643                }
644            }
645        }
646
647        MenuOutcome::Continue
648    }
649
650    /// Select item at position.
651    #[inline]
652    pub fn select_at(&mut self, pos: (u16, u16)) -> bool {
653        let old_selected = self.selected;
654
655        // before first render or no items:
656        if self.disabled.is_empty() {
657            return false;
658        }
659
660        if let Some(idx) = self.mouse.item_at(&self.item_areas, pos.0, pos.1) {
661            if !self.disabled[idx] {
662                self.selected = Some(idx);
663            }
664        }
665
666        self.selected != old_selected
667    }
668
669    /// Item at position.
670    #[inline]
671    pub fn item_at(&self, pos: (u16, u16)) -> Option<usize> {
672        self.mouse.item_at(&self.item_areas, pos.0, pos.1)
673    }
674}
675
676impl HandleEvent<crossterm::event::Event, Popup, MenuOutcome> for PopupMenuState {
677    fn handle(&mut self, event: &crossterm::event::Event, _qualifier: Popup) -> MenuOutcome {
678        let r0 = match self.popup.handle(event, Popup) {
679            PopupOutcome::Hide => MenuOutcome::Hide,
680            r => r.into(),
681        };
682
683        let r1 = if self.is_active() {
684            match event {
685                ct_event!(key press ANY-c) => {
686                    let r = self.navigate(*c);
687                    if matches!(r, MenuOutcome::Activated(_)) {
688                        self.set_active(false);
689                    }
690                    r
691                }
692                ct_event!(keycode press Up) => {
693                    if self.prev_item() {
694                        if let Some(selected) = self.selected {
695                            MenuOutcome::Selected(selected)
696                        } else {
697                            MenuOutcome::Changed
698                        }
699                    } else {
700                        MenuOutcome::Continue
701                    }
702                }
703                ct_event!(keycode press Down) => {
704                    if self.next_item() {
705                        if let Some(selected) = self.selected {
706                            MenuOutcome::Selected(selected)
707                        } else {
708                            MenuOutcome::Changed
709                        }
710                    } else {
711                        MenuOutcome::Continue
712                    }
713                }
714                ct_event!(keycode press Home) => {
715                    if self.select(Some(0)) {
716                        if let Some(selected) = self.selected {
717                            MenuOutcome::Selected(selected)
718                        } else {
719                            MenuOutcome::Changed
720                        }
721                    } else {
722                        MenuOutcome::Continue
723                    }
724                }
725                ct_event!(keycode press End) => {
726                    if self.select(Some(self.len().saturating_sub(1))) {
727                        if let Some(selected) = self.selected {
728                            MenuOutcome::Selected(selected)
729                        } else {
730                            MenuOutcome::Changed
731                        }
732                    } else {
733                        MenuOutcome::Continue
734                    }
735                }
736                ct_event!(keycode press Esc) => {
737                    self.set_active(false);
738                    MenuOutcome::Changed
739                }
740                ct_event!(keycode press Enter) => {
741                    if let Some(select) = self.selected {
742                        self.set_active(false);
743                        MenuOutcome::Activated(select)
744                    } else {
745                        MenuOutcome::Continue
746                    }
747                }
748
749                _ => MenuOutcome::Continue,
750            }
751        } else {
752            MenuOutcome::Continue
753        };
754
755        let r = max(r0, r1);
756
757        if !r.is_consumed() {
758            self.handle(event, MouseOnly)
759        } else {
760            r
761        }
762    }
763}
764
765impl HandleEvent<crossterm::event::Event, MouseOnly, MenuOutcome> for PopupMenuState {
766    fn handle(&mut self, event: &crossterm::event::Event, _: MouseOnly) -> MenuOutcome {
767        if self.is_active() {
768            let r = match event {
769                ct_event!(mouse moved for col, row)
770                    if self.popup.widget_area.contains((*col, *row).into()) =>
771                {
772                    if self.select_at((*col, *row)) {
773                        MenuOutcome::Selected(self.selected().expect("selection"))
774                    } else {
775                        MenuOutcome::Unchanged
776                    }
777                }
778                ct_event!(mouse down Left for col, row)
779                    if self.popup.widget_area.contains((*col, *row).into()) =>
780                {
781                    if self.item_at((*col, *row)).is_some() {
782                        self.set_active(false);
783                        MenuOutcome::Activated(self.selected().expect("selection"))
784                    } else {
785                        MenuOutcome::Unchanged
786                    }
787                }
788                _ => MenuOutcome::Continue,
789            };
790
791            r.or_else(|| mouse_trap(event, self.popup.area).into())
792        } else {
793            MenuOutcome::Continue
794        }
795    }
796}
797
798/// Handle all events.
799/// The assumption is, the popup-menu is focused or it is hidden.
800/// This state must be handled outside of this widget.
801pub fn handle_popup_events(
802    state: &mut PopupMenuState,
803    event: &crossterm::event::Event,
804) -> MenuOutcome {
805    state.handle(event, Popup)
806}
807
808/// Handle only mouse-events.
809pub fn handle_mouse_events(
810    state: &mut PopupMenuState,
811    event: &crossterm::event::Event,
812) -> MenuOutcome {
813    state.handle(event, MouseOnly)
814}