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 #[deprecated(since = "1.0.5", note = "no longer useful")]
489 pub fn named(_: &'static str) -> Self {
490 Self {
491 popup: PopupCoreState::new(),
492 ..Default::default()
493 }
494 }
495
496 pub fn set_popup_z(&mut self, z: u16) {
498 self.popup.area_z = z;
499 }
500
501 pub fn popup_z(&self) -> u16 {
503 self.popup.area_z
504 }
505
506 pub fn flip_active(&mut self) {
508 self.popup.flip_active();
509 }
510
511 pub fn is_active(&self) -> bool {
513 self.popup.is_active()
514 }
515
516 pub fn set_active(&mut self, active: bool) {
518 self.popup.set_active(active);
519 if !active {
520 self.clear_areas();
521 }
522 }
523
524 pub fn clear_areas(&mut self) {
526 self.popup.clear_areas();
527 self.sep_areas.clear();
528 self.navchar.clear();
529 self.item_areas.clear();
530 self.disabled.clear();
531 }
532
533 #[inline]
535 pub fn len(&self) -> usize {
536 self.item_areas.len()
537 }
538
539 #[inline]
541 pub fn is_empty(&self) -> bool {
542 self.item_areas.is_empty()
543 }
544
545 #[inline]
547 pub fn select(&mut self, select: Option<usize>) -> bool {
548 let old = self.selected;
549 self.selected = select;
550 old != self.selected
551 }
552
553 #[inline]
555 pub fn selected(&self) -> Option<usize> {
556 self.selected
557 }
558
559 #[inline]
561 pub fn prev_item(&mut self) -> bool {
562 let old = self.selected;
563
564 if self.disabled.is_empty() {
566 return false;
567 }
568
569 self.selected = if let Some(start) = old {
570 let mut idx = start;
571 loop {
572 if idx == 0 {
573 idx = start;
574 break;
575 }
576 idx -= 1;
577
578 if self.disabled.get(idx) == Some(&false) {
579 break;
580 }
581 }
582 Some(idx)
583 } else if !self.is_empty() {
584 Some(self.len() - 1)
585 } else {
586 None
587 };
588
589 old != self.selected
590 }
591
592 #[inline]
594 pub fn next_item(&mut self) -> bool {
595 let old = self.selected;
596
597 if self.disabled.is_empty() {
599 return false;
600 }
601
602 self.selected = if let Some(start) = old {
603 let mut idx = start;
604 loop {
605 if idx + 1 == self.len() {
606 idx = start;
607 break;
608 }
609 idx += 1;
610
611 if self.disabled.get(idx) == Some(&false) {
612 break;
613 }
614 }
615 Some(idx)
616 } else if !self.is_empty() {
617 Some(0)
618 } else {
619 None
620 };
621
622 old != self.selected
623 }
624
625 #[inline]
627 pub fn navigate(&mut self, c: char) -> MenuOutcome {
628 if self.disabled.is_empty() {
630 return MenuOutcome::Continue;
631 }
632
633 let c = c.to_ascii_lowercase();
634 for (i, cc) in self.navchar.iter().enumerate() {
635 #[allow(clippy::collapsible_if)]
636 if *cc == Some(c) {
637 if self.disabled.get(i) == Some(&false) {
638 if self.selected == Some(i) {
639 return MenuOutcome::Activated(i);
640 } else {
641 self.selected = Some(i);
642 return MenuOutcome::Selected(i);
643 }
644 }
645 }
646 }
647
648 MenuOutcome::Continue
649 }
650
651 #[inline]
653 pub fn select_at(&mut self, pos: (u16, u16)) -> bool {
654 let old_selected = self.selected;
655
656 if self.disabled.is_empty() {
658 return false;
659 }
660
661 if let Some(idx) = self.mouse.item_at(&self.item_areas, pos.0, pos.1) {
662 if !self.disabled[idx] {
663 self.selected = Some(idx);
664 }
665 }
666
667 self.selected != old_selected
668 }
669
670 #[inline]
672 pub fn item_at(&self, pos: (u16, u16)) -> Option<usize> {
673 self.mouse.item_at(&self.item_areas, pos.0, pos.1)
674 }
675}
676
677impl HandleEvent<crossterm::event::Event, Popup, MenuOutcome> for PopupMenuState {
678 fn handle(&mut self, event: &crossterm::event::Event, _qualifier: Popup) -> MenuOutcome {
679 let r0 = match self.popup.handle(event, Popup) {
680 PopupOutcome::Hide => MenuOutcome::Hide,
681 r => r.into(),
682 };
683
684 let r1 = if self.is_active() {
685 match event {
686 ct_event!(key press ANY-c) => {
687 let r = self.navigate(*c);
688 if matches!(r, MenuOutcome::Activated(_)) {
689 self.set_active(false);
690 }
691 r
692 }
693 ct_event!(keycode press Up) => {
694 if self.prev_item() {
695 if let Some(selected) = self.selected {
696 MenuOutcome::Selected(selected)
697 } else {
698 MenuOutcome::Changed
699 }
700 } else {
701 MenuOutcome::Continue
702 }
703 }
704 ct_event!(keycode press Down) => {
705 if self.next_item() {
706 if let Some(selected) = self.selected {
707 MenuOutcome::Selected(selected)
708 } else {
709 MenuOutcome::Changed
710 }
711 } else {
712 MenuOutcome::Continue
713 }
714 }
715 ct_event!(keycode press Home) => {
716 if self.select(Some(0)) {
717 if let Some(selected) = self.selected {
718 MenuOutcome::Selected(selected)
719 } else {
720 MenuOutcome::Changed
721 }
722 } else {
723 MenuOutcome::Continue
724 }
725 }
726 ct_event!(keycode press End) => {
727 if self.select(Some(self.len().saturating_sub(1))) {
728 if let Some(selected) = self.selected {
729 MenuOutcome::Selected(selected)
730 } else {
731 MenuOutcome::Changed
732 }
733 } else {
734 MenuOutcome::Continue
735 }
736 }
737 ct_event!(keycode press Esc) => {
738 self.set_active(false);
739 MenuOutcome::Changed
740 }
741 ct_event!(keycode press Enter) => {
742 if let Some(select) = self.selected {
743 self.set_active(false);
744 MenuOutcome::Activated(select)
745 } else {
746 MenuOutcome::Continue
747 }
748 }
749
750 _ => MenuOutcome::Continue,
751 }
752 } else {
753 MenuOutcome::Continue
754 };
755
756 let r = max(r0, r1);
757
758 if !r.is_consumed() {
759 self.handle(event, MouseOnly)
760 } else {
761 r
762 }
763 }
764}
765
766impl HandleEvent<crossterm::event::Event, MouseOnly, MenuOutcome> for PopupMenuState {
767 fn handle(&mut self, event: &crossterm::event::Event, _: MouseOnly) -> MenuOutcome {
768 if self.is_active() {
769 let r = match event {
770 ct_event!(mouse moved for col, row)
771 if self.popup.widget_area.contains((*col, *row).into()) =>
772 {
773 if self.select_at((*col, *row)) {
774 MenuOutcome::Selected(self.selected().expect("selection"))
775 } else {
776 MenuOutcome::Unchanged
777 }
778 }
779 ct_event!(mouse down Left for col, row)
780 if self.popup.widget_area.contains((*col, *row).into()) =>
781 {
782 if self.item_at((*col, *row)).is_some() {
783 self.set_active(false);
784 MenuOutcome::Activated(self.selected().expect("selection"))
785 } else {
786 MenuOutcome::Unchanged
787 }
788 }
789 _ => MenuOutcome::Continue,
790 };
791
792 r.or_else(|| mouse_trap(event, self.popup.area).into())
793 } else {
794 MenuOutcome::Continue
795 }
796 }
797}
798
799pub fn handle_popup_events(
803 state: &mut PopupMenuState,
804 event: &crossterm::event::Event,
805) -> MenuOutcome {
806 state.handle(event, Popup)
807}
808
809pub fn handle_mouse_events(
811 state: &mut PopupMenuState,
812 event: &crossterm::event::Event,
813) -> MenuOutcome {
814 state.handle(event, MouseOnly)
815}