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,
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 if let Some(popup_style) = styles.popup_style {
265 self.style = popup_style;
266 } else {
267 self.style = styles.style;
268 }
269
270 self.block = self.block.map(|v| v.style(self.style));
271 if let Some(border_style) = styles.popup_border {
272 self.block = self.block.map(|v| v.border_style(border_style));
273 }
274 if styles.block.is_some() {
275 self.block = styles.block;
276 }
277
278 if styles.highlight.is_some() {
279 self.highlight_style = styles.highlight;
280 }
281 if styles.disabled.is_some() {
282 self.disabled_style = styles.disabled;
283 }
284 if styles.right.is_some() {
285 self.right_style = styles.right;
286 }
287 if styles.focus.is_some() {
288 self.focus_style = styles.focus;
289 }
290 self
291 }
292
293 pub fn style(mut self, style: Style) -> Self {
295 self.style = style;
296 self.block = self.block.map(|v| v.style(self.style));
297 self
298 }
299
300 pub fn highlight_style(mut self, style: Style) -> Self {
302 self.highlight_style = Some(style);
303 self
304 }
305
306 pub fn highlight_style_opt(mut self, style: Option<Style>) -> Self {
308 self.highlight_style = style;
309 self
310 }
311
312 #[inline]
314 pub fn disabled_style(mut self, style: Style) -> Self {
315 self.disabled_style = Some(style);
316 self
317 }
318
319 #[inline]
321 pub fn disabled_style_opt(mut self, style: Option<Style>) -> Self {
322 self.disabled_style = style;
323 self
324 }
325
326 #[inline]
328 pub fn right_style(mut self, style: Style) -> Self {
329 self.right_style = Some(style);
330 self
331 }
332
333 #[inline]
335 pub fn right_style_opt(mut self, style: Option<Style>) -> Self {
336 self.right_style = style;
337 self
338 }
339
340 pub fn focus_style(mut self, style: Style) -> Self {
342 self.focus_style = Some(style);
343 self
344 }
345
346 pub fn focus_style_opt(mut self, style: Option<Style>) -> Self {
348 self.focus_style = style;
349 self
350 }
351
352 pub fn block(mut self, block: Block<'a>) -> Self {
354 self.block = Some(block);
355 self.block = self.block.map(|v| v.style(self.style));
356 self
357 }
358
359 pub fn block_opt(mut self, block: Option<Block<'a>>) -> Self {
361 self.block = block;
362 self.block = self.block.map(|v| v.style(self.style));
363 self
364 }
365
366 pub fn get_block_size(&self) -> Size {
368 get_block_size(&self.block)
369 }
370
371 pub fn get_block_padding(&self) -> Padding {
373 get_block_padding(&self.block)
374 }
375}
376
377impl<'a> StatefulWidget for &PopupMenu<'a> {
378 type State = PopupMenuState;
379
380 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
381 render_popup_menu(self, area, buf, state);
382 }
383}
384
385impl StatefulWidget for PopupMenu<'_> {
386 type State = PopupMenuState;
387
388 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
389 render_popup_menu(&self, area, buf, state);
390 }
391}
392
393fn render_popup_menu(
394 widget: &PopupMenu<'_>,
395 _area: Rect,
396 buf: &mut Buffer,
397 state: &mut PopupMenuState,
398) {
399 if widget.menu.items.is_empty() {
400 state.selected = None;
401 } else if state.selected.is_none() {
402 state.selected = Some(0);
403 }
404
405 state.navchar = widget.menu.items.iter().map(|v| v.navchar).collect();
406 state.disabled = widget.menu.items.iter().map(|v| v.disabled).collect();
407
408 if !state.is_active() {
409 state.clear_areas();
410 return;
411 }
412
413 let size = widget.size();
414 let area = Rect::new(0, 0, size.width, size.height);
415
416 (&widget.popup).render(area, buf, &mut state.popup);
417 if widget.block.is_some() {
418 widget.block.render(state.popup.area, buf);
419 } else {
420 buf.set_style(state.popup.area, widget.style);
421 }
422 widget.layout(state.popup.area, state);
423 render_items(widget, buf, state);
424}
425
426fn render_items(widget: &PopupMenu<'_>, buf: &mut Buffer, state: &mut PopupMenuState) {
427 let focus_style = widget.focus_style.unwrap_or(revert_style(widget.style));
428
429 let style = widget.style;
430 let right_style = style.patch(widget.right_style.unwrap_or_default());
431 let highlight_style = style.patch(widget.highlight_style.unwrap_or(Style::new().underlined()));
432 let disabled_style = style.patch(widget.disabled_style.unwrap_or_default());
433
434 let sel_style = focus_style;
435 let sel_right_style = focus_style.patch(widget.right_style.unwrap_or_default());
436 let sel_highlight_style = focus_style;
437 let sel_disabled_style = focus_style.patch(widget.disabled_style.unwrap_or_default());
438
439 for (n, item) in widget.menu.items.iter().enumerate() {
440 let mut item_area = state.item_areas[n];
441
442 #[allow(clippy::collapsible_else_if)]
443 let (style, right_style, highlight_style) = if state.selected == Some(n) {
444 if item.disabled {
445 (sel_disabled_style, sel_right_style, sel_highlight_style)
446 } else {
447 (sel_style, sel_right_style, sel_highlight_style)
448 }
449 } else {
450 if item.disabled {
451 (disabled_style, right_style, highlight_style)
452 } else {
453 (style, right_style, highlight_style)
454 }
455 };
456
457 let item_line = if let Some(highlight) = item.highlight.clone() {
458 Line::from_iter([
459 Span::from(&item.item[..highlight.start - 1]), Span::from(&item.item[highlight.start..highlight.end]).style(highlight_style),
461 Span::from(&item.item[highlight.end..]),
462 ])
463 } else {
464 Line::from(item.item.as_ref())
465 };
466 item_line.style(style).render(item_area, buf);
467
468 if !item.right.is_empty() {
469 let right_width = item.right.graphemes(true).count() as u16;
470 if right_width < item_area.width {
471 let delta = item_area.width.saturating_sub(right_width);
472 item_area.x += delta;
473 item_area.width -= delta;
474 }
475 Span::from(item.right.as_ref())
476 .style(right_style)
477 .render(item_area, buf);
478 }
479
480 if let Some(separator) = item.separator {
481 let sep_area = state.sep_areas[n];
482 let sym = match separator {
483 Separator::Empty => " ",
484 Separator::Plain => "\u{2500}",
485 Separator::Thick => "\u{2501}",
486 Separator::Double => "\u{2550}",
487 Separator::Dashed => "\u{2212}",
488 Separator::Dotted => "\u{2508}",
489 };
490 for x in 0..sep_area.width {
491 if let Some(cell) = buf.cell_mut((sep_area.x + x, sep_area.y)) {
492 cell.set_symbol(sym);
493 }
494 }
495 }
496 }
497}
498
499impl PopupMenuState {
500 #[inline]
502 pub fn new() -> Self {
503 Default::default()
504 }
505
506 pub fn set_popup_z(&mut self, z: u16) {
508 self.popup.area_z = z;
509 }
510
511 pub fn popup_z(&self) -> u16 {
513 self.popup.area_z
514 }
515
516 pub fn flip_active(&mut self) {
518 self.popup.flip_active();
519 }
520
521 pub fn is_active(&self) -> bool {
523 self.popup.is_active()
524 }
525
526 pub fn set_active(&mut self, active: bool) {
528 self.popup.set_active(active);
529 if !active {
530 self.clear_areas();
531 }
532 }
533
534 pub fn clear_areas(&mut self) {
536 self.popup.clear_areas();
537 self.sep_areas.clear();
538 self.navchar.clear();
539 self.item_areas.clear();
540 self.disabled.clear();
541 }
542
543 #[inline]
545 pub fn len(&self) -> usize {
546 self.item_areas.len()
547 }
548
549 #[inline]
551 pub fn is_empty(&self) -> bool {
552 self.item_areas.is_empty()
553 }
554
555 #[inline]
557 pub fn select(&mut self, select: Option<usize>) -> bool {
558 let old = self.selected;
559 self.selected = select;
560 old != self.selected
561 }
562
563 #[inline]
565 pub fn selected(&self) -> Option<usize> {
566 self.selected
567 }
568
569 #[inline]
571 pub fn prev_item(&mut self) -> bool {
572 let old = self.selected;
573
574 if self.disabled.is_empty() {
576 return false;
577 }
578
579 self.selected = if let Some(start) = old {
580 let mut idx = start;
581 loop {
582 if idx == 0 {
583 idx = start;
584 break;
585 }
586 idx -= 1;
587
588 if self.disabled.get(idx) == Some(&false) {
589 break;
590 }
591 }
592 Some(idx)
593 } else if !self.is_empty() {
594 Some(self.len() - 1)
595 } else {
596 None
597 };
598
599 old != self.selected
600 }
601
602 #[inline]
604 pub fn next_item(&mut self) -> bool {
605 let old = self.selected;
606
607 if self.disabled.is_empty() {
609 return false;
610 }
611
612 self.selected = if let Some(start) = old {
613 let mut idx = start;
614 loop {
615 if idx + 1 == self.len() {
616 idx = start;
617 break;
618 }
619 idx += 1;
620
621 if self.disabled.get(idx) == Some(&false) {
622 break;
623 }
624 }
625 Some(idx)
626 } else if !self.is_empty() {
627 Some(0)
628 } else {
629 None
630 };
631
632 old != self.selected
633 }
634
635 #[inline]
637 pub fn navigate(&mut self, c: char) -> MenuOutcome {
638 if self.disabled.is_empty() {
640 return MenuOutcome::Continue;
641 }
642
643 let c = c.to_ascii_lowercase();
644 for (i, cc) in self.navchar.iter().enumerate() {
645 #[allow(clippy::collapsible_if)]
646 if *cc == Some(c) {
647 if self.disabled.get(i) == Some(&false) {
648 if self.selected == Some(i) {
649 return MenuOutcome::Activated(i);
650 } else {
651 self.selected = Some(i);
652 return MenuOutcome::Selected(i);
653 }
654 }
655 }
656 }
657
658 MenuOutcome::Continue
659 }
660
661 #[inline]
663 #[allow(clippy::collapsible_if)]
664 pub fn select_at(&mut self, pos: (u16, u16)) -> bool {
665 let old_selected = self.selected;
666
667 if self.disabled.is_empty() {
669 return false;
670 }
671
672 if let Some(idx) = self.mouse.item_at(&self.item_areas, pos.0, pos.1) {
673 if !self.disabled[idx] {
674 self.selected = Some(idx);
675 }
676 }
677
678 self.selected != old_selected
679 }
680
681 #[inline]
683 pub fn item_at(&self, pos: (u16, u16)) -> Option<usize> {
684 self.mouse.item_at(&self.item_areas, pos.0, pos.1)
685 }
686}
687
688impl HandleEvent<crossterm::event::Event, Popup, MenuOutcome> for PopupMenuState {
689 fn handle(&mut self, event: &crossterm::event::Event, _qualifier: Popup) -> MenuOutcome {
699 let r0 = match self.popup.handle(event, Popup) {
700 PopupOutcome::Hide => MenuOutcome::Hide,
701 r => r.into(),
702 };
703
704 let r1 = if self.is_active() {
705 match event {
706 ct_event!(key press ANY-c) => {
707 let r = self.navigate(*c);
708 if matches!(r, MenuOutcome::Activated(_)) {
709 self.set_active(false);
710 }
711 r
712 }
713 ct_event!(keycode press Up) => {
714 if self.prev_item() {
715 if let Some(selected) = self.selected {
716 MenuOutcome::Selected(selected)
717 } else {
718 MenuOutcome::Changed
719 }
720 } else {
721 MenuOutcome::Continue
722 }
723 }
724 ct_event!(keycode press Down) => {
725 if self.next_item() {
726 if let Some(selected) = self.selected {
727 MenuOutcome::Selected(selected)
728 } else {
729 MenuOutcome::Changed
730 }
731 } else {
732 MenuOutcome::Continue
733 }
734 }
735 ct_event!(keycode press Home) => {
736 if self.select(Some(0)) {
737 if let Some(selected) = self.selected {
738 MenuOutcome::Selected(selected)
739 } else {
740 MenuOutcome::Changed
741 }
742 } else {
743 MenuOutcome::Continue
744 }
745 }
746 ct_event!(keycode press End) => {
747 if self.select(Some(self.len().saturating_sub(1))) {
748 if let Some(selected) = self.selected {
749 MenuOutcome::Selected(selected)
750 } else {
751 MenuOutcome::Changed
752 }
753 } else {
754 MenuOutcome::Continue
755 }
756 }
757 ct_event!(keycode press Esc) => {
758 self.set_active(false);
759 MenuOutcome::Changed
760 }
761 ct_event!(keycode press Enter) => {
762 if let Some(select) = self.selected {
763 self.set_active(false);
764 MenuOutcome::Activated(select)
765 } else {
766 MenuOutcome::Continue
767 }
768 }
769
770 _ => MenuOutcome::Continue,
771 }
772 } else {
773 MenuOutcome::Continue
774 };
775
776 let r = max(r0, r1);
777
778 if !r.is_consumed() {
779 self.handle(event, MouseOnly)
780 } else {
781 r
782 }
783 }
784}
785
786impl HandleEvent<crossterm::event::Event, MouseOnly, MenuOutcome> for PopupMenuState {
787 fn handle(&mut self, event: &crossterm::event::Event, _: MouseOnly) -> MenuOutcome {
788 if self.is_active() {
789 let r = match event {
790 ct_event!(mouse moved for col, row)
791 if self.popup.area.contains((*col, *row).into()) =>
792 {
793 if self.select_at((*col, *row)) {
794 MenuOutcome::Selected(self.selected().expect("selection"))
795 } else {
796 MenuOutcome::Unchanged
797 }
798 }
799 ct_event!(mouse down Left for col, row)
800 if self.popup.area.contains((*col, *row).into()) =>
801 {
802 if self.item_at((*col, *row)).is_some() {
803 self.set_active(false);
804 MenuOutcome::Activated(self.selected().expect("selection"))
805 } else {
806 MenuOutcome::Unchanged
807 }
808 }
809 _ => MenuOutcome::Continue,
810 };
811
812 r.or_else(|| mouse_trap(event, self.popup.area).into())
813 } else {
814 MenuOutcome::Continue
815 }
816 }
817}
818
819pub fn handle_popup_events(
829 state: &mut PopupMenuState,
830 event: &crossterm::event::Event,
831) -> MenuOutcome {
832 state.handle(event, Popup)
833}
834
835pub fn handle_mouse_events(
837 state: &mut PopupMenuState,
838 event: &crossterm::event::Event,
839) -> MenuOutcome {
840 state.handle(event, MouseOnly)
841}