use crate::_private::NonExhaustive;
use crate::event::MenuOutcome;
use crate::util::{get_block_padding, get_block_size, revert_style};
use crate::{MenuBuilder, MenuItem, MenuStyle, Separator};
use rat_cursor::HasScreenCursor;
use rat_event::util::{MouseFlags, mouse_trap};
use rat_event::{ConsumedEvent, HandleEvent, MouseOnly, Popup, Regular, ct_event};
use rat_focus::{FocusBuilder, FocusFlag, HasFocus, Navigation};
pub use rat_popup::PopupConstraint;
use rat_popup::event::PopupOutcome;
use rat_popup::{PopupCore, PopupCoreState};
use rat_reloc::RelocatableState;
use ratatui_core::buffer::Buffer;
use ratatui_core::layout::{Rect, Size};
use ratatui_core::style::Style;
use ratatui_core::text::{Line, Span};
use ratatui_core::widgets::StatefulWidget;
use ratatui_core::widgets::Widget;
use ratatui_crossterm::crossterm::event::Event;
use ratatui_widgets::block::{Block, BlockExt, Padding};
use std::cmp::max;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug, Default, Clone)]
pub struct PopupMenu<'a> {
pub(crate) menu: MenuBuilder<'a>,
pub(crate) popup: PopupCore,
width: Option<u16>,
style: Style,
block: Option<Block<'a>>,
highlight_style: Option<Style>,
disabled_style: Option<Style>,
right_style: Option<Style>,
focus_style: Option<Style>,
separator_style: Option<Style>,
}
#[derive(Debug, Clone)]
pub struct PopupMenuState {
pub area: Rect,
pub popup: PopupCoreState,
pub item_areas: Vec<Rect>,
pub sep_areas: Vec<Rect>,
pub navchar: Vec<Option<char>>,
pub disabled: Vec<bool>,
pub selected: Option<usize>,
pub focus: FocusFlag,
pub mouse: MouseFlags,
pub non_exhaustive: NonExhaustive,
}
impl Default for PopupMenuState {
fn default() -> Self {
Self {
area: Default::default(),
popup: Default::default(),
item_areas: Default::default(),
sep_areas: Default::default(),
navchar: Default::default(),
disabled: Default::default(),
selected: Default::default(),
focus: Default::default(),
mouse: Default::default(),
non_exhaustive: NonExhaustive,
}
}
}
impl PopupMenu<'_> {
fn size(&self) -> Size {
let width = if let Some(width) = self.width {
width
} else {
let text_width = self
.menu
.items
.iter()
.map(|v| (v.item_width() * 3) / 2 + v.right_width())
.max();
text_width.unwrap_or(10)
};
let height = self.menu.items.iter().map(MenuItem::height).sum::<u16>();
let block = get_block_size(&self.block);
#[allow(clippy::if_same_then_else)]
let vertical_padding = if block.height == 0 { 2 } else { 0 };
let horizontal_padding = 2;
Size::new(
width + horizontal_padding + block.width,
height + vertical_padding + block.height,
)
}
fn layout(&self, area: Rect, state: &mut PopupMenuState) {
let block = get_block_size(&self.block);
let inner = self.block.inner_if_some(area);
#[allow(clippy::if_same_then_else)]
let vert_offset = if block.height == 0 { 1 } else { 0 };
let horiz_offset = 1;
let horiz_offset_sep = 0;
state.item_areas.clear();
state.sep_areas.clear();
let mut row = 0;
for item in &self.menu.items {
state.item_areas.push(Rect::new(
inner.x + horiz_offset,
inner.y + row + vert_offset,
inner.width.saturating_sub(2 * horiz_offset),
1,
));
state.sep_areas.push(Rect::new(
inner.x + horiz_offset_sep,
inner.y + row + 1 + vert_offset,
inner.width.saturating_sub(2 * horiz_offset_sep),
if item.separator.is_some() { 1 } else { 0 },
));
row += item.height();
}
}
}
impl<'a> PopupMenu<'a> {
pub fn new() -> Self {
Default::default()
}
pub fn item(mut self, item: MenuItem<'a>) -> Self {
self.menu.item(item);
self
}
pub fn item_parsed(mut self, text: &'a str) -> Self {
self.menu.item_parsed(text);
self
}
pub fn item_str(mut self, txt: &'a str) -> Self {
self.menu.item_str(txt);
self
}
pub fn item_string(mut self, txt: String) -> Self {
self.menu.item_string(txt);
self
}
pub fn separator(mut self, separator: Separator) -> Self {
self.menu.separator(separator);
self
}
pub fn menu_width(mut self, width: u16) -> Self {
self.width = Some(width);
self
}
pub fn menu_width_opt(mut self, width: Option<u16>) -> Self {
self.width = width;
self
}
pub fn constraint(mut self, placement: PopupConstraint) -> Self {
self.popup = self.popup.constraint(placement);
self
}
pub fn offset(mut self, offset: (i16, i16)) -> Self {
self.popup = self.popup.offset(offset);
self
}
pub fn x_offset(mut self, offset: i16) -> Self {
self.popup = self.popup.x_offset(offset);
self
}
pub fn y_offset(mut self, offset: i16) -> Self {
self.popup = self.popup.y_offset(offset);
self
}
pub fn boundary(mut self, boundary: Rect) -> Self {
self.popup = self.popup.boundary(boundary);
self
}
#[allow(deprecated)]
pub fn styles(mut self, styles: MenuStyle) -> Self {
self.popup = self.popup.styles(styles.popup.clone());
if let Some(popup_style) = styles.popup_style {
self.style = popup_style;
} else {
self.style = styles.style;
}
if styles.block.is_some() {
self.block = styles.block;
}
if styles.popup_block.is_some() {
self.block = styles.popup_block;
}
if let Some(title_style) = styles.popup_title {
self.block = self.block.map(|v| v.title_style(title_style));
}
if let Some(border_style) = styles.popup_border {
self.block = self.block.map(|v| v.border_style(border_style));
}
self.block = self.block.map(|v| v.style(self.style));
if styles.popup_highlight.is_some() {
self.highlight_style = styles.popup_highlight;
}
if styles.popup_disabled.is_some() {
self.disabled_style = styles.popup_disabled;
}
if styles.popup_right.is_some() {
self.right_style = styles.popup_right;
}
if styles.popup_focus.is_some() {
self.focus_style = styles.popup_focus;
}
if styles.popup_separator.is_some() {
self.separator_style = styles.popup_separator;
}
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self.block = self.block.map(|v| v.style(self.style));
self
}
pub fn highlight_style(mut self, style: Style) -> Self {
self.highlight_style = Some(style);
self
}
pub fn highlight_style_opt(mut self, style: Option<Style>) -> Self {
self.highlight_style = style;
self
}
#[inline]
pub fn disabled_style(mut self, style: Style) -> Self {
self.disabled_style = Some(style);
self
}
#[inline]
pub fn disabled_style_opt(mut self, style: Option<Style>) -> Self {
self.disabled_style = style;
self
}
#[inline]
pub fn right_style(mut self, style: Style) -> Self {
self.right_style = Some(style);
self
}
#[inline]
pub fn right_style_opt(mut self, style: Option<Style>) -> Self {
self.right_style = style;
self
}
pub fn focus_style(mut self, style: Style) -> Self {
self.focus_style = Some(style);
self
}
pub fn focus_style_opt(mut self, style: Option<Style>) -> Self {
self.focus_style = style;
self
}
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self.block = self.block.map(|v| v.style(self.style));
self
}
pub fn block_opt(mut self, block: Option<Block<'a>>) -> Self {
self.block = block;
self.block = self.block.map(|v| v.style(self.style));
self
}
pub fn get_block_size(&self) -> Size {
get_block_size(&self.block)
}
pub fn get_block_padding(&self) -> Padding {
get_block_padding(&self.block)
}
pub fn width(&self) -> u16 {
self.size().width
}
pub fn height(&self) -> u16 {
self.size().height
}
}
impl<'a> StatefulWidget for &PopupMenu<'a> {
type State = PopupMenuState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
render_popup_menu(self, area, buf, state);
}
}
impl StatefulWidget for PopupMenu<'_> {
type State = PopupMenuState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
render_popup_menu(&self, area, buf, state);
}
}
fn render_popup_menu(
widget: &PopupMenu<'_>,
_area: Rect,
buf: &mut Buffer,
state: &mut PopupMenuState,
) {
if widget.menu.items.is_empty() {
state.selected = None;
} else if state.selected.is_none() {
state.selected = Some(0);
}
state.navchar = widget.menu.items.iter().map(|v| v.navchar).collect();
state.disabled = widget.menu.items.iter().map(|v| v.disabled).collect();
if !state.is_active() {
state.relocate_popup_hidden();
return;
}
let size = widget.size();
let area = Rect::new(0, 0, size.width, size.height);
(&widget.popup).render(area, buf, &mut state.popup);
state.area = state.popup.area;
if widget.block.is_some() {
widget.block.clone().render(state.popup.area, buf);
} else {
buf.set_style(state.popup.area, widget.style);
}
widget.layout(state.popup.area, state);
render_items(widget, buf, state);
}
fn render_items(widget: &PopupMenu<'_>, buf: &mut Buffer, state: &mut PopupMenuState) {
let focus_style = widget.focus_style.unwrap_or(revert_style(widget.style));
let style = widget.style;
let right_style = style.patch(widget.right_style.unwrap_or_default());
let highlight_style = style.patch(widget.highlight_style.unwrap_or(Style::new().underlined()));
let disabled_style = style.patch(widget.disabled_style.unwrap_or_default());
let separator_style = style.patch(widget.separator_style.unwrap_or_default());
let sel_style = focus_style;
let sel_right_style = focus_style.patch(widget.right_style.unwrap_or_default());
let sel_highlight_style = focus_style;
let sel_disabled_style = focus_style.patch(widget.disabled_style.unwrap_or_default());
for (n, item) in widget.menu.items.iter().enumerate() {
let mut item_area = state.item_areas[n];
#[allow(clippy::collapsible_else_if)]
let (style, right_style, highlight_style) = if state.selected == Some(n) {
if item.disabled {
(sel_disabled_style, sel_right_style, sel_highlight_style)
} else {
(sel_style, sel_right_style, sel_highlight_style)
}
} else {
if item.disabled {
(disabled_style, right_style, highlight_style)
} else {
(style, right_style, highlight_style)
}
};
let item_line = if let Some(highlight) = item.highlight.clone() {
Line::from_iter([
Span::from(&item.item[..highlight.start - 1]), Span::from(&item.item[highlight.start..highlight.end]).style(highlight_style),
Span::from(&item.item[highlight.end..]),
])
} else {
Line::from(item.item.as_ref())
};
item_line.style(style).render(item_area, buf);
if !item.right.is_empty() {
let right_width = item.right.graphemes(true).count() as u16;
if right_width < item_area.width {
let delta = item_area.width.saturating_sub(right_width);
item_area.x += delta;
item_area.width -= delta;
}
Span::from(item.right.as_ref())
.style(right_style)
.render(item_area, buf);
}
if let Some(separator) = item.separator {
let sep_area = state.sep_areas[n];
let sym = match separator {
Separator::Empty => " ",
Separator::Plain => "\u{2500}",
Separator::Thick => "\u{2501}",
Separator::Double => "\u{2550}",
Separator::Dashed => "\u{2212}",
Separator::Dotted => "\u{2508}",
};
for x in 0..sep_area.width {
if let Some(cell) = buf.cell_mut((sep_area.x + x, sep_area.y)) {
cell.set_symbol(sym);
cell.set_style(separator_style);
}
}
}
}
}
impl HasFocus for PopupMenuState {
fn build(&self, builder: &mut FocusBuilder) {
builder.leaf_widget(self);
}
fn focus(&self) -> FocusFlag {
self.focus.clone()
}
fn area(&self) -> Rect {
self.popup.area
}
fn area_z(&self) -> u16 {
self.popup.area_z
}
fn navigable(&self) -> Navigation {
if self.is_active() {
Navigation::Leave
} else {
Navigation::None
}
}
}
impl HasScreenCursor for PopupMenuState {
fn screen_cursor(&self) -> Option<(u16, u16)> {
None
}
}
impl RelocatableState for PopupMenuState {
fn relocate(&mut self, _shift: (i16, i16), _clip: Rect) {}
fn relocate_popup(&mut self, shift: (i16, i16), clip: Rect) {
self.area.relocate(shift, clip);
self.popup.relocate_popup(shift, clip);
self.item_areas.relocate(shift, clip);
self.sep_areas.relocate(shift, clip);
}
}
impl PopupMenuState {
#[inline]
pub fn new() -> Self {
Default::default()
}
pub fn named(name: &str) -> Self {
let mut z = Self::default();
z.focus = z.focus.with_name(name);
z
}
pub fn set_popup_z(&mut self, z: u16) {
self.popup.area_z = z;
}
pub fn popup_z(&self) -> u16 {
self.popup.area_z
}
pub fn flip_active(&mut self) {
self.popup.flip_active();
}
pub fn is_active(&self) -> bool {
self.popup.is_active()
}
pub fn set_active(&mut self, active: bool) {
self.popup.set_active(active);
if !active {
self.relocate_popup_hidden();
}
}
#[deprecated(since = "2.1.0", note = "use relocate_popup_hidden()")]
pub fn clear_areas(&mut self) {
self.area = Rect::default();
self.popup.clear_areas();
self.sep_areas.clear();
self.navchar.clear();
self.item_areas.clear();
self.disabled.clear();
}
#[inline]
pub fn len(&self) -> usize {
self.item_areas.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.item_areas.is_empty()
}
#[inline]
pub fn select(&mut self, select: Option<usize>) -> bool {
let old = self.selected;
self.selected = select;
old != self.selected
}
#[inline]
pub fn selected(&self) -> Option<usize> {
self.selected
}
#[inline]
pub fn prev_item(&mut self) -> bool {
let old = self.selected;
if self.disabled.is_empty() {
return false;
}
self.selected = if let Some(start) = old {
let mut idx = start;
loop {
if idx == 0 {
idx = start;
break;
}
idx -= 1;
if self.disabled.get(idx) == Some(&false) {
break;
}
}
Some(idx)
} else if !self.is_empty() {
Some(self.len() - 1)
} else {
None
};
old != self.selected
}
#[inline]
pub fn next_item(&mut self) -> bool {
let old = self.selected;
if self.disabled.is_empty() {
return false;
}
self.selected = if let Some(start) = old {
let mut idx = start;
loop {
if idx + 1 == self.len() {
idx = start;
break;
}
idx += 1;
if self.disabled.get(idx) == Some(&false) {
break;
}
}
Some(idx)
} else if !self.is_empty() {
Some(0)
} else {
None
};
old != self.selected
}
#[inline]
pub fn navigate(&mut self, c: char) -> MenuOutcome {
if self.disabled.is_empty() {
return MenuOutcome::Continue;
}
let c = c.to_ascii_lowercase();
for (i, cc) in self.navchar.iter().enumerate() {
#[allow(clippy::collapsible_if)]
if *cc == Some(c) {
if self.disabled.get(i) == Some(&false) {
if self.selected == Some(i) {
return MenuOutcome::Activated(i);
} else {
self.selected = Some(i);
return MenuOutcome::Selected(i);
}
}
}
}
MenuOutcome::Continue
}
#[inline]
#[allow(clippy::collapsible_if)]
pub fn select_at(&mut self, pos: (u16, u16)) -> bool {
let old_selected = self.selected;
if self.disabled.is_empty() {
return false;
}
if let Some(idx) = self.mouse.item_at(&self.item_areas, pos.0, pos.1) {
if !self.disabled[idx] {
self.selected = Some(idx);
}
}
self.selected != old_selected
}
#[inline]
pub fn item_at(&self, pos: (u16, u16)) -> Option<usize> {
self.mouse.item_at(&self.item_areas, pos.0, pos.1)
}
}
impl HandleEvent<Event, Regular, MenuOutcome> for PopupMenuState {
fn handle(&mut self, event: &Event, _qualifier: Regular) -> MenuOutcome {
self.handle(event, Popup)
}
}
impl HandleEvent<Event, Popup, MenuOutcome> for PopupMenuState {
fn handle(&mut self, event: &Event, _qualifier: Popup) -> MenuOutcome {
let r0 = match self.popup.handle(event, Popup) {
PopupOutcome::Hide => MenuOutcome::Hide,
r => r.into(),
};
let r1 = if self.is_active() {
match event {
ct_event!(key press ANY-c) => {
let r = self.navigate(*c);
if matches!(r, MenuOutcome::Activated(_)) {
self.set_active(false);
}
r
}
ct_event!(keycode press Up) => {
if self.prev_item() {
if let Some(selected) = self.selected {
MenuOutcome::Selected(selected)
} else {
MenuOutcome::Changed
}
} else {
MenuOutcome::Continue
}
}
ct_event!(keycode press Down) => {
if self.next_item() {
if let Some(selected) = self.selected {
MenuOutcome::Selected(selected)
} else {
MenuOutcome::Changed
}
} else {
MenuOutcome::Continue
}
}
ct_event!(keycode press Home) => {
if self.select(Some(0)) {
if let Some(selected) = self.selected {
MenuOutcome::Selected(selected)
} else {
MenuOutcome::Changed
}
} else {
MenuOutcome::Continue
}
}
ct_event!(keycode press End) => {
if self.select(Some(self.len().saturating_sub(1))) {
if let Some(selected) = self.selected {
MenuOutcome::Selected(selected)
} else {
MenuOutcome::Changed
}
} else {
MenuOutcome::Continue
}
}
ct_event!(keycode press Esc) => {
self.set_active(false);
MenuOutcome::Changed
}
ct_event!(keycode press Enter) => {
if let Some(select) = self.selected {
self.set_active(false);
MenuOutcome::Activated(select)
} else {
MenuOutcome::Continue
}
}
_ => MenuOutcome::Continue,
}
} else {
MenuOutcome::Continue
};
let r = max(r0, r1);
if !r.is_consumed() {
self.handle(event, MouseOnly)
} else {
r
}
}
}
impl HandleEvent<Event, MouseOnly, MenuOutcome> for PopupMenuState {
fn handle(&mut self, event: &Event, _: MouseOnly) -> MenuOutcome {
if self.is_active() {
if !self.has_mouse_focus() {
return MenuOutcome::Continue;
}
let r = match event {
ct_event!(mouse moved for col, row)
if self.popup.area.contains((*col, *row).into()) =>
{
if self.select_at((*col, *row)) {
MenuOutcome::Selected(self.selected().expect("selection"))
} else {
MenuOutcome::Unchanged
}
}
ct_event!(mouse down Left for col, row)
if self.popup.area.contains((*col, *row).into()) =>
{
if self.item_at((*col, *row)).is_some() {
self.set_active(false);
MenuOutcome::Activated(self.selected().expect("selection"))
} else {
MenuOutcome::Unchanged
}
}
_ => MenuOutcome::Continue,
};
r.or_else(|| mouse_trap(event, self.popup.area).into())
} else {
MenuOutcome::Continue
}
}
}
pub fn handle_events(state: &mut PopupMenuState, _focus: bool, event: &Event) -> MenuOutcome {
state.handle(event, Popup)
}
pub fn handle_popup_events(state: &mut PopupMenuState, event: &Event) -> MenuOutcome {
state.handle(event, Popup)
}
pub fn handle_mouse_events(state: &mut PopupMenuState, event: &Event) -> MenuOutcome {
state.handle(event, MouseOnly)
}