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