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