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