1use crate::_private::NonExhaustive;
37use crate::event::MenuOutcome;
38use crate::util::{get_block_padding, get_block_size, revert_style};
39use crate::{MenuBuilder, MenuItem, MenuStyle, Separator};
40use rat_cursor::HasScreenCursor;
41use rat_event::util::{MouseFlags, mouse_trap};
42use rat_event::{ConsumedEvent, HandleEvent, MouseOnly, Popup, ct_event};
43use rat_focus::{FocusBuilder, FocusFlag, HasFocus};
44pub use rat_popup::PopupConstraint;
45use rat_popup::event::PopupOutcome;
46use rat_popup::{PopupCore, PopupCoreState};
47use rat_reloc::RelocatableState;
48use ratatui::buffer::Buffer;
49use ratatui::layout::{Rect, Size};
50use ratatui::prelude::BlockExt;
51use ratatui::style::{Style, Stylize};
52use ratatui::text::{Line, Span};
53use ratatui::widgets::StatefulWidget;
54use ratatui::widgets::{Block, Padding, Widget};
55use std::cmp::max;
56use unicode_segmentation::UnicodeSegmentation;
57
58#[derive(Debug, Default, Clone)]
60pub struct PopupMenu<'a> {
61 pub(crate) menu: MenuBuilder<'a>,
62 pub(crate) popup: PopupCore,
63
64 width: Option<u16>,
65
66 style: Style,
67 block: Option<Block<'a>>,
68 highlight_style: Option<Style>,
69 disabled_style: Option<Style>,
70 right_style: Option<Style>,
71 focus_style: Option<Style>,
72 separator_style: Option<Style>,
73}
74
75#[derive(Debug, Clone)]
77pub struct PopupMenuState {
78 pub area: Rect,
79 pub popup: PopupCoreState,
81 pub item_areas: Vec<Rect>,
84 pub sep_areas: Vec<Rect>,
88 pub navchar: Vec<Option<char>>,
91 pub disabled: Vec<bool>,
93 pub selected: Option<usize>,
96
97 pub mouse: MouseFlags,
100
101 pub non_exhaustive: NonExhaustive,
102}
103
104impl Default for PopupMenuState {
105 fn default() -> Self {
106 Self {
107 area: Default::default(),
108 popup: Default::default(),
109 item_areas: Default::default(),
110 sep_areas: Default::default(),
111 navchar: Default::default(),
112 disabled: Default::default(),
113 selected: Default::default(),
114 mouse: Default::default(),
115 non_exhaustive: NonExhaustive,
116 }
117 }
118}
119
120impl PopupMenu<'_> {
121 fn size(&self) -> Size {
122 let width = if let Some(width) = self.width {
123 width
124 } else {
125 let text_width = self
126 .menu
127 .items
128 .iter()
129 .map(|v| (v.item_width() * 3) / 2 + v.right_width())
130 .max();
131 text_width.unwrap_or(10)
132 };
133 let height = self.menu.items.iter().map(MenuItem::height).sum::<u16>();
134
135 let block = get_block_size(&self.block);
136
137 #[allow(clippy::if_same_then_else)]
138 let vertical_padding = if block.height == 0 { 2 } else { 0 };
139 let horizontal_padding = 2;
140
141 Size::new(
142 width + horizontal_padding + block.width,
143 height + vertical_padding + block.height,
144 )
145 }
146
147 fn layout(&self, area: Rect, state: &mut PopupMenuState) {
148 let block = get_block_size(&self.block);
149 let inner = self.block.inner_if_some(area);
150
151 #[allow(clippy::if_same_then_else)]
153 let vert_offset = if block.height == 0 { 1 } else { 0 };
154 let horiz_offset = 1;
155 let horiz_offset_sep = 0;
156
157 state.item_areas.clear();
158 state.sep_areas.clear();
159
160 let mut row = 0;
161
162 for item in &self.menu.items {
163 state.item_areas.push(Rect::new(
164 inner.x + horiz_offset,
165 inner.y + row + vert_offset,
166 inner.width.saturating_sub(2 * horiz_offset),
167 1,
168 ));
169 state.sep_areas.push(Rect::new(
170 inner.x + horiz_offset_sep,
171 inner.y + row + 1 + vert_offset,
172 inner.width.saturating_sub(2 * horiz_offset_sep),
173 if item.separator.is_some() { 1 } else { 0 },
174 ));
175
176 row += item.height();
177 }
178 }
179}
180
181impl<'a> PopupMenu<'a> {
182 pub fn new() -> Self {
184 Default::default()
185 }
186
187 pub fn item(mut self, item: MenuItem<'a>) -> Self {
189 self.menu.item(item);
190 self
191 }
192
193 pub fn item_parsed(mut self, text: &'a str) -> Self {
199 self.menu.item_parsed(text);
200 self
201 }
202
203 pub fn item_str(mut self, txt: &'a str) -> Self {
205 self.menu.item_str(txt);
206 self
207 }
208
209 pub fn item_string(mut self, txt: String) -> Self {
211 self.menu.item_string(txt);
212 self
213 }
214
215 pub fn separator(mut self, separator: Separator) -> Self {
218 self.menu.separator(separator);
219 self
220 }
221
222 pub fn menu_width(mut self, width: u16) -> Self {
225 self.width = Some(width);
226 self
227 }
228
229 pub fn menu_width_opt(mut self, width: Option<u16>) -> Self {
232 self.width = width;
233 self
234 }
235
236 pub fn constraint(mut self, placement: PopupConstraint) -> Self {
238 self.popup = self.popup.constraint(placement);
239 self
240 }
241
242 pub fn offset(mut self, offset: (i16, i16)) -> Self {
244 self.popup = self.popup.offset(offset);
245 self
246 }
247
248 pub fn x_offset(mut self, offset: i16) -> Self {
250 self.popup = self.popup.x_offset(offset);
251 self
252 }
253
254 pub fn y_offset(mut self, offset: i16) -> Self {
256 self.popup = self.popup.y_offset(offset);
257 self
258 }
259
260 pub fn boundary(mut self, boundary: Rect) -> Self {
263 self.popup = self.popup.boundary(boundary);
264 self
265 }
266
267 #[allow(deprecated)]
269 pub fn styles(mut self, styles: MenuStyle) -> Self {
270 self.popup = self.popup.styles(styles.popup.clone());
271
272 if let Some(popup_style) = styles.popup_style {
273 self.style = popup_style;
274 } else {
275 self.style = styles.style;
276 }
277
278 if styles.block.is_some() {
279 self.block = styles.block;
280 }
281 if styles.popup_block.is_some() {
282 self.block = styles.popup_block;
283 }
284 if let Some(title_style) = styles.popup_title {
285 self.block = self.block.map(|v| v.title_style(title_style));
286 }
287 if let Some(border_style) = styles.popup_border {
288 self.block = self.block.map(|v| v.border_style(border_style));
289 }
290 self.block = self.block.map(|v| v.style(self.style));
291
292 if styles.popup_highlight.is_some() {
293 self.highlight_style = styles.popup_highlight;
294 }
295 if styles.popup_disabled.is_some() {
296 self.disabled_style = styles.popup_disabled;
297 }
298 if styles.popup_right.is_some() {
299 self.right_style = styles.popup_right;
300 }
301 if styles.popup_focus.is_some() {
302 self.focus_style = styles.popup_focus;
303 }
304 if styles.popup_separator.is_some() {
305 self.separator_style = styles.popup_separator;
306 }
307 self
308 }
309
310 pub fn style(mut self, style: Style) -> Self {
312 self.style = style;
313 self.block = self.block.map(|v| v.style(self.style));
314 self
315 }
316
317 pub fn highlight_style(mut self, style: Style) -> Self {
319 self.highlight_style = Some(style);
320 self
321 }
322
323 pub fn highlight_style_opt(mut self, style: Option<Style>) -> Self {
325 self.highlight_style = style;
326 self
327 }
328
329 #[inline]
331 pub fn disabled_style(mut self, style: Style) -> Self {
332 self.disabled_style = Some(style);
333 self
334 }
335
336 #[inline]
338 pub fn disabled_style_opt(mut self, style: Option<Style>) -> Self {
339 self.disabled_style = style;
340 self
341 }
342
343 #[inline]
345 pub fn right_style(mut self, style: Style) -> Self {
346 self.right_style = Some(style);
347 self
348 }
349
350 #[inline]
352 pub fn right_style_opt(mut self, style: Option<Style>) -> Self {
353 self.right_style = style;
354 self
355 }
356
357 pub fn focus_style(mut self, style: Style) -> Self {
359 self.focus_style = Some(style);
360 self
361 }
362
363 pub fn focus_style_opt(mut self, style: Option<Style>) -> Self {
365 self.focus_style = style;
366 self
367 }
368
369 pub fn block(mut self, block: Block<'a>) -> Self {
371 self.block = Some(block);
372 self.block = self.block.map(|v| v.style(self.style));
373 self
374 }
375
376 pub fn block_opt(mut self, block: Option<Block<'a>>) -> Self {
378 self.block = block;
379 self.block = self.block.map(|v| v.style(self.style));
380 self
381 }
382
383 pub fn get_block_size(&self) -> Size {
385 get_block_size(&self.block)
386 }
387
388 pub fn get_block_padding(&self) -> Padding {
390 get_block_padding(&self.block)
391 }
392
393 pub fn width(&self) -> u16 {
394 self.size().width
395 }
396
397 pub fn height(&self) -> u16 {
398 self.size().height
399 }
400}
401
402impl<'a> StatefulWidget for &PopupMenu<'a> {
403 type State = PopupMenuState;
404
405 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
406 render_popup_menu(self, area, buf, state);
407 }
408}
409
410impl StatefulWidget for PopupMenu<'_> {
411 type State = PopupMenuState;
412
413 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
414 render_popup_menu(&self, area, buf, state);
415 }
416}
417
418fn render_popup_menu(
419 widget: &PopupMenu<'_>,
420 _area: Rect,
421 buf: &mut Buffer,
422 state: &mut PopupMenuState,
423) {
424 if widget.menu.items.is_empty() {
425 state.selected = None;
426 } else if state.selected.is_none() {
427 state.selected = Some(0);
428 }
429
430 state.navchar = widget.menu.items.iter().map(|v| v.navchar).collect();
431 state.disabled = widget.menu.items.iter().map(|v| v.disabled).collect();
432
433 if !state.is_active() {
434 state.relocate_popup_hidden();
435 return;
436 }
437
438 let size = widget.size();
439 let area = Rect::new(0, 0, size.width, size.height);
440
441 (&widget.popup).render(area, buf, &mut state.popup);
442 state.area = state.popup.area;
443
444 if widget.block.is_some() {
445 widget.block.render(state.popup.area, buf);
446 } else {
447 buf.set_style(state.popup.area, widget.style);
448 }
449 widget.layout(state.popup.area, state);
450 render_items(widget, buf, state);
451}
452
453fn render_items(widget: &PopupMenu<'_>, buf: &mut Buffer, state: &mut PopupMenuState) {
454 let focus_style = widget.focus_style.unwrap_or(revert_style(widget.style));
455
456 let style = widget.style;
457 let right_style = style.patch(widget.right_style.unwrap_or_default());
458 let highlight_style = style.patch(widget.highlight_style.unwrap_or(Style::new().underlined()));
459 let disabled_style = style.patch(widget.disabled_style.unwrap_or_default());
460 let separator_style = style.patch(widget.separator_style.unwrap_or_default());
461
462 let sel_style = focus_style;
463 let sel_right_style = focus_style.patch(widget.right_style.unwrap_or_default());
464 let sel_highlight_style = focus_style;
465 let sel_disabled_style = focus_style.patch(widget.disabled_style.unwrap_or_default());
466
467 for (n, item) in widget.menu.items.iter().enumerate() {
468 let mut item_area = state.item_areas[n];
469
470 #[allow(clippy::collapsible_else_if)]
471 let (style, right_style, highlight_style) = if state.selected == Some(n) {
472 if item.disabled {
473 (sel_disabled_style, sel_right_style, sel_highlight_style)
474 } else {
475 (sel_style, sel_right_style, sel_highlight_style)
476 }
477 } else {
478 if item.disabled {
479 (disabled_style, right_style, highlight_style)
480 } else {
481 (style, right_style, highlight_style)
482 }
483 };
484
485 let item_line = if let Some(highlight) = item.highlight.clone() {
486 Line::from_iter([
487 Span::from(&item.item[..highlight.start - 1]), Span::from(&item.item[highlight.start..highlight.end]).style(highlight_style),
489 Span::from(&item.item[highlight.end..]),
490 ])
491 } else {
492 Line::from(item.item.as_ref())
493 };
494 item_line.style(style).render(item_area, buf);
495
496 if !item.right.is_empty() {
497 let right_width = item.right.graphemes(true).count() as u16;
498 if right_width < item_area.width {
499 let delta = item_area.width.saturating_sub(right_width);
500 item_area.x += delta;
501 item_area.width -= delta;
502 }
503 Span::from(item.right.as_ref())
504 .style(right_style)
505 .render(item_area, buf);
506 }
507
508 if let Some(separator) = item.separator {
509 let sep_area = state.sep_areas[n];
510 let sym = match separator {
511 Separator::Empty => " ",
512 Separator::Plain => "\u{2500}",
513 Separator::Thick => "\u{2501}",
514 Separator::Double => "\u{2550}",
515 Separator::Dashed => "\u{2212}",
516 Separator::Dotted => "\u{2508}",
517 };
518 for x in 0..sep_area.width {
519 if let Some(cell) = buf.cell_mut((sep_area.x + x, sep_area.y)) {
520 cell.set_symbol(sym);
521 cell.set_style(separator_style);
522 }
523 }
524 }
525 }
526}
527
528impl HasFocus for PopupMenuState {
529 fn build(&self, _builder: &mut FocusBuilder) {
530 }
532
533 fn focus(&self) -> FocusFlag {
534 unimplemented!("not available")
535 }
536
537 fn area(&self) -> Rect {
538 unimplemented!("not available")
539 }
540}
541
542impl HasScreenCursor for PopupMenuState {
543 fn screen_cursor(&self) -> Option<(u16, u16)> {
544 None
545 }
546}
547
548impl RelocatableState for PopupMenuState {
549 fn relocate(&mut self, _shift: (i16, i16), _clip: Rect) {}
550
551 fn relocate_popup(&mut self, shift: (i16, i16), clip: Rect) {
552 self.area.relocate(shift, clip);
553 self.popup.relocate_popup(shift, clip);
554 self.item_areas.relocate(shift, clip);
555 self.sep_areas.relocate(shift, clip);
556 }
557}
558
559impl PopupMenuState {
560 #[inline]
562 pub fn new() -> Self {
563 Default::default()
564 }
565
566 #[inline]
568 pub fn named(_name: &str) -> Self {
569 Default::default()
570 }
571
572 pub fn set_popup_z(&mut self, z: u16) {
574 self.popup.area_z = z;
575 }
576
577 pub fn popup_z(&self) -> u16 {
579 self.popup.area_z
580 }
581
582 pub fn flip_active(&mut self) {
584 self.popup.flip_active();
585 }
586
587 pub fn is_active(&self) -> bool {
589 self.popup.is_active()
590 }
591
592 pub fn set_active(&mut self, active: bool) {
594 self.popup.set_active(active);
595 if !active {
596 self.relocate_popup_hidden();
597 }
598 }
599
600 #[deprecated(since = "2.1.0", note = "use relocate_popup_hidden()")]
602 pub fn clear_areas(&mut self) {
603 self.area = Rect::default();
604 self.popup.clear_areas();
605 self.sep_areas.clear();
606 self.navchar.clear();
607 self.item_areas.clear();
608 self.disabled.clear();
609 }
610
611 #[inline]
613 pub fn len(&self) -> usize {
614 self.item_areas.len()
615 }
616
617 #[inline]
619 pub fn is_empty(&self) -> bool {
620 self.item_areas.is_empty()
621 }
622
623 #[inline]
625 pub fn select(&mut self, select: Option<usize>) -> bool {
626 let old = self.selected;
627 self.selected = select;
628 old != self.selected
629 }
630
631 #[inline]
633 pub fn selected(&self) -> Option<usize> {
634 self.selected
635 }
636
637 #[inline]
639 pub fn prev_item(&mut self) -> bool {
640 let old = self.selected;
641
642 if self.disabled.is_empty() {
644 return false;
645 }
646
647 self.selected = if let Some(start) = old {
648 let mut idx = start;
649 loop {
650 if idx == 0 {
651 idx = start;
652 break;
653 }
654 idx -= 1;
655
656 if self.disabled.get(idx) == Some(&false) {
657 break;
658 }
659 }
660 Some(idx)
661 } else if !self.is_empty() {
662 Some(self.len() - 1)
663 } else {
664 None
665 };
666
667 old != self.selected
668 }
669
670 #[inline]
672 pub fn next_item(&mut self) -> bool {
673 let old = self.selected;
674
675 if self.disabled.is_empty() {
677 return false;
678 }
679
680 self.selected = if let Some(start) = old {
681 let mut idx = start;
682 loop {
683 if idx + 1 == self.len() {
684 idx = start;
685 break;
686 }
687 idx += 1;
688
689 if self.disabled.get(idx) == Some(&false) {
690 break;
691 }
692 }
693 Some(idx)
694 } else if !self.is_empty() {
695 Some(0)
696 } else {
697 None
698 };
699
700 old != self.selected
701 }
702
703 #[inline]
705 pub fn navigate(&mut self, c: char) -> MenuOutcome {
706 if self.disabled.is_empty() {
708 return MenuOutcome::Continue;
709 }
710
711 let c = c.to_ascii_lowercase();
712 for (i, cc) in self.navchar.iter().enumerate() {
713 #[allow(clippy::collapsible_if)]
714 if *cc == Some(c) {
715 if self.disabled.get(i) == Some(&false) {
716 if self.selected == Some(i) {
717 return MenuOutcome::Activated(i);
718 } else {
719 self.selected = Some(i);
720 return MenuOutcome::Selected(i);
721 }
722 }
723 }
724 }
725
726 MenuOutcome::Continue
727 }
728
729 #[inline]
731 #[allow(clippy::collapsible_if)]
732 pub fn select_at(&mut self, pos: (u16, u16)) -> bool {
733 let old_selected = self.selected;
734
735 if self.disabled.is_empty() {
737 return false;
738 }
739
740 if let Some(idx) = self.mouse.item_at(&self.item_areas, pos.0, pos.1) {
741 if !self.disabled[idx] {
742 self.selected = Some(idx);
743 }
744 }
745
746 self.selected != old_selected
747 }
748
749 #[inline]
751 pub fn item_at(&self, pos: (u16, u16)) -> Option<usize> {
752 self.mouse.item_at(&self.item_areas, pos.0, pos.1)
753 }
754}
755
756impl HandleEvent<crossterm::event::Event, Popup, MenuOutcome> for PopupMenuState {
757 fn handle(&mut self, event: &crossterm::event::Event, _qualifier: Popup) -> MenuOutcome {
767 let r0 = match self.popup.handle(event, Popup) {
768 PopupOutcome::Hide => MenuOutcome::Hide,
769 r => r.into(),
770 };
771
772 let r1 = if self.is_active() {
773 match event {
774 ct_event!(key press ANY-c) => {
775 let r = self.navigate(*c);
776 if matches!(r, MenuOutcome::Activated(_)) {
777 self.set_active(false);
778 }
779 r
780 }
781 ct_event!(keycode press Up) => {
782 if self.prev_item() {
783 if let Some(selected) = self.selected {
784 MenuOutcome::Selected(selected)
785 } else {
786 MenuOutcome::Changed
787 }
788 } else {
789 MenuOutcome::Continue
790 }
791 }
792 ct_event!(keycode press Down) => {
793 if self.next_item() {
794 if let Some(selected) = self.selected {
795 MenuOutcome::Selected(selected)
796 } else {
797 MenuOutcome::Changed
798 }
799 } else {
800 MenuOutcome::Continue
801 }
802 }
803 ct_event!(keycode press Home) => {
804 if self.select(Some(0)) {
805 if let Some(selected) = self.selected {
806 MenuOutcome::Selected(selected)
807 } else {
808 MenuOutcome::Changed
809 }
810 } else {
811 MenuOutcome::Continue
812 }
813 }
814 ct_event!(keycode press End) => {
815 if self.select(Some(self.len().saturating_sub(1))) {
816 if let Some(selected) = self.selected {
817 MenuOutcome::Selected(selected)
818 } else {
819 MenuOutcome::Changed
820 }
821 } else {
822 MenuOutcome::Continue
823 }
824 }
825 ct_event!(keycode press Esc) => {
826 self.set_active(false);
827 MenuOutcome::Changed
828 }
829 ct_event!(keycode press Enter) => {
830 if let Some(select) = self.selected {
831 self.set_active(false);
832 MenuOutcome::Activated(select)
833 } else {
834 MenuOutcome::Continue
835 }
836 }
837
838 _ => MenuOutcome::Continue,
839 }
840 } else {
841 MenuOutcome::Continue
842 };
843
844 let r = max(r0, r1);
845
846 if !r.is_consumed() {
847 self.handle(event, MouseOnly)
848 } else {
849 r
850 }
851 }
852}
853
854impl HandleEvent<crossterm::event::Event, MouseOnly, MenuOutcome> for PopupMenuState {
855 fn handle(&mut self, event: &crossterm::event::Event, _: MouseOnly) -> MenuOutcome {
856 if self.is_active() {
857 let r = match event {
858 ct_event!(mouse moved for col, row)
859 if self.popup.area.contains((*col, *row).into()) =>
860 {
861 if self.select_at((*col, *row)) {
862 MenuOutcome::Selected(self.selected().expect("selection"))
863 } else {
864 MenuOutcome::Unchanged
865 }
866 }
867 ct_event!(mouse down Left for col, row)
868 if self.popup.area.contains((*col, *row).into()) =>
869 {
870 if self.item_at((*col, *row)).is_some() {
871 self.set_active(false);
872 MenuOutcome::Activated(self.selected().expect("selection"))
873 } else {
874 MenuOutcome::Unchanged
875 }
876 }
877 _ => MenuOutcome::Continue,
878 };
879
880 r.or_else(|| mouse_trap(event, self.popup.area).into())
881 } else {
882 MenuOutcome::Continue
883 }
884 }
885}
886
887pub fn handle_events(
889 state: &mut PopupMenuState,
890 _focus: bool,
891 event: &crossterm::event::Event,
892) -> MenuOutcome {
893 state.handle(event, Popup)
894}
895
896pub fn handle_popup_events(
906 state: &mut PopupMenuState,
907 event: &crossterm::event::Event,
908) -> MenuOutcome {
909 state.handle(event, Popup)
910}
911
912pub fn handle_mouse_events(
914 state: &mut PopupMenuState,
915 event: &crossterm::event::Event,
916) -> MenuOutcome {
917 state.handle(event, MouseOnly)
918}