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