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