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