use crate::_private::NonExhaustive;
use crate::event::Popup;
use crate::menuline::{MenuOutcome, MenuStyle};
use crate::util::{fill_buf_area, menu_str, next_opt, prev_opt, revert_style};
use rat_event::util::MouseFlags;
use rat_event::{ct_event, ConsumedEvent, HandleEvent, MouseOnly};
use rat_focus::{FocusFlag, HasFocusFlag, Navigation, ZRect};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::StatefulWidget;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::widgets::{Block, Widget};
#[cfg(feature = "unstable-widget-ref")]
use ratatui::widgets::{StatefulWidgetRef, WidgetRef};
use std::mem;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum Placement {
#[default]
Top,
Left,
Right,
Bottom,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Separator {
#[default]
None,
Empty,
Plain,
Thick,
Double,
Dashed,
Dotted,
}
#[derive(Debug, Clone)]
pub enum MenuItem<'a> {
Item(Line<'a>),
Item2(Line<'a>, Option<char>),
Item3(Line<'a>, Option<char>, Line<'a>),
Sep(Separator),
}
#[derive(Debug, Default, Clone)]
pub struct PopupMenu<'a> {
items: Vec<Item<'a>>,
navchar: Vec<Option<char>>,
width: Option<u16>,
placement: Placement,
boundary: Option<Rect>,
style: Style,
focus_style: Option<Style>,
block: Option<Block<'a>>,
block_indent: bool,
}
#[derive(Debug, Clone)]
pub struct PopupMenuState {
pub focus: FocusFlag,
pub area: Rect,
pub z_areas: [ZRect; 1],
pub item_areas: Vec<Rect>,
pub sep_areas: Vec<Rect>,
pub navchar: Vec<Option<char>>,
pub selected: Option<usize>,
pub mouse: MouseFlags,
pub non_exhaustive: NonExhaustive,
}
#[derive(Debug, Default, Clone)]
struct Item<'a> {
line: Line<'a>,
right: Option<Line<'a>>,
sep: Separator,
}
impl<'a> Item<'a> {
fn width(&self) -> u16 {
self.line.width() as u16
}
fn height(&self) -> u16 {
if self.sep == Separator::None {
1
} else {
2
}
}
}
impl Default for PopupMenuState {
fn default() -> Self {
Self {
focus: Default::default(),
area: Default::default(),
z_areas: [Default::default()],
item_areas: vec![],
sep_areas: vec![],
navchar: vec![],
selected: None,
mouse: Default::default(),
non_exhaustive: NonExhaustive,
}
}
}
impl<'a> PopupMenu<'a> {
fn layout(&self, area: Rect, fit_in: Rect, state: &mut PopupMenuState) {
let width = if let Some(width) = self.width {
width
} else {
let text_width = self.items.iter().map(|v| v.width()).max();
(text_width.unwrap_or(10) * 3) / 2
};
let height = self.items.iter().map(Item::height).sum::<u16>();
let vertical_margin = if self.block_indent { 1 } else { 1 };
let horizontal_margin = if self.block_indent { 2 } else { 1 };
let horizontal_offset_sep = if self.block_indent { 1 } else { 0 };
let mut area = match self.placement {
Placement::Top => Rect::new(
area.x.saturating_sub(horizontal_margin),
area.y.saturating_sub(height + vertical_margin * 2),
width + horizontal_margin * 2,
height + vertical_margin * 2,
),
Placement::Left => Rect::new(
area.x.saturating_sub(width + horizontal_margin * 2),
area.y,
width + horizontal_margin * 2,
height + vertical_margin * 2,
),
Placement::Right => Rect::new(
area.x + area.width,
area.y,
width + horizontal_margin * 2,
height + vertical_margin * 2,
),
Placement::Bottom => Rect::new(
area.x.saturating_sub(horizontal_margin),
area.y + area.height,
width + horizontal_margin * 2,
height + vertical_margin * 2,
),
};
if area.right() >= fit_in.right() {
area.x -= area.right() - fit_in.right();
}
if area.bottom() >= fit_in.bottom() {
area.y -= area.bottom() - fit_in.bottom();
}
state.area = area;
state.z_areas[0] = ZRect::from((1, area));
state.item_areas.clear();
state.sep_areas.clear();
let mut row = 0;
for item in &self.items {
state.item_areas.push(Rect::new(
area.x + horizontal_margin,
row + area.y + vertical_margin,
width,
1,
));
if item.sep == Separator::None {
state.sep_areas.push(Rect::new(
area.x + horizontal_offset_sep,
row + 1 + area.y + vertical_margin,
width + 2,
0,
));
} else {
state.sep_areas.push(Rect::new(
area.x + horizontal_offset_sep,
row + 1 + area.y + vertical_margin,
width + 2,
1,
));
}
row += item.height();
}
}
}
impl<'a> PopupMenu<'a> {
pub fn new() -> Self {
Default::default()
}
pub fn add(mut self, item: Line<'a>, navchar: Option<char>) -> Self {
self.items.push(Item {
line: item,
right: None,
sep: Default::default(),
});
self.navchar.push(navchar);
self
}
pub fn add_ext(mut self, item: Line<'a>, navchar: Option<char>, item_right: Line<'a>) -> Self {
self.items.push(Item {
line: item,
right: Some(item_right),
sep: Default::default(),
});
self.navchar.push(navchar);
self
}
pub fn add_sep(mut self, ty: Separator) -> Self {
if let Some(last) = self.items.last_mut() {
last.sep = ty;
}
self
}
pub fn add_str(mut self, txt: &'a str) -> Self {
let (item, navchar) = menu_str(txt);
self.items.push(Item {
line: item,
right: None,
sep: Default::default(),
});
self.navchar.push(navchar);
self
}
pub fn add_item(self, item: MenuItem<'a>) -> Self {
match item {
MenuItem::Item(txt) => self.add(txt, None),
MenuItem::Item2(txt, navchar) => self.add(txt, navchar),
MenuItem::Item3(txt, navchar, txt2) => self.add_ext(txt, navchar, txt2),
MenuItem::Sep(sep) => self.add_sep(sep),
}
}
pub fn width(mut self, width: u16) -> Self {
self.width = Some(width);
self
}
pub fn placement(mut self, placement: Placement) -> Self {
self.placement = placement;
self
}
pub fn boundary(mut self, boundary: Rect) -> Self {
self.boundary = Some(boundary);
self
}
pub fn styles(mut self, styles: MenuStyle) -> Self {
self.style = styles.style;
self.focus_style = styles.focus;
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn focus_style(mut self, style: Style) -> Self {
self.focus_style = Some(style);
self
}
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self.block_indent = true;
self
}
}
#[cfg(feature = "unstable-widget-ref")]
impl<'a> StatefulWidgetRef for PopupMenu<'a> {
type State = PopupMenuState;
fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
render_ref(
self,
|area, buf| self.block.render_ref(area, buf),
area,
buf,
state,
);
}
}
impl<'a> StatefulWidget for PopupMenu<'a> {
type State = PopupMenuState;
fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let block = mem::take(&mut self.block);
render_ref(
&self, |area, buf| block.render(area, buf),
area,
buf,
state,
);
}
}
fn render_ref(
widget: &PopupMenu<'_>,
block: impl FnOnce(Rect, &mut Buffer),
area: Rect,
buf: &mut Buffer,
state: &mut PopupMenuState,
) {
if !state.active() {
state.clear();
return;
}
state.navchar = widget.navchar.clone();
let fit_in = if let Some(boundary) = widget.boundary {
boundary
} else {
buf.area
};
widget.layout(area, fit_in, state);
fill_buf_area(buf, state.area, " ", widget.style);
block(state.area, buf);
for (n, txt) in widget.items.iter().enumerate() {
let mut it_area = state.item_areas[n];
let style = if state.selected == Some(n) {
if let Some(focus) = widget.focus_style {
focus
} else {
revert_style(widget.style)
}
} else {
widget.style
};
buf.set_style(it_area, style);
txt.line.clone().render(it_area, buf);
if let Some(txt_right) = &txt.right {
let txt_width = txt_right.width() as u16;
if txt_width < it_area.width {
let delta = it_area.width.saturating_sub(txt_width);
it_area.x += delta;
it_area.width -= delta;
}
txt_right.clone().render(it_area, buf);
}
if txt.sep != Separator::None {
let sep_area = state.sep_areas[n];
buf.set_style(sep_area, widget.style);
let sym = match txt.sep {
Separator::Empty => " ",
Separator::Plain => "\u{2500}",
Separator::Thick => "\u{2501}",
Separator::Double => "\u{2550}",
Separator::Dashed => "\u{2212}",
Separator::Dotted => "\u{2508}",
Separator::None => {
unreachable!()
}
};
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);
}
}
}
}
}
impl HasFocusFlag for PopupMenuState {
fn focus(&self) -> FocusFlag {
self.focus.clone()
}
fn area(&self) -> Rect {
self.area
}
fn z_areas(&self) -> &[ZRect] {
&self.z_areas
}
fn navigable(&self) -> Navigation {
Navigation::Leave
}
}
impl PopupMenuState {
#[inline]
pub fn new() -> Self {
Default::default()
}
pub fn named(name: &str) -> Self {
Self {
focus: FocusFlag::named(name),
..Default::default()
}
}
pub fn clear(&mut self) {
*self = Default::default();
}
pub fn flip_active(&mut self) {
self.focus.set(!self.focus.get());
}
pub fn active(&self) -> bool {
self.is_focused()
}
pub fn set_active(&self, active: bool) {
self.focus.set(active);
}
#[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;
self.selected = prev_opt(self.selected, 1, self.len());
old != self.selected
}
#[inline]
pub fn next_item(&mut self) -> bool {
let old = self.selected;
self.selected = next_opt(self.selected, 1, self.len());
old != self.selected
}
#[inline]
pub fn navigate(&mut self, c: char) -> MenuOutcome {
let c = c.to_ascii_lowercase();
for (i, cc) in self.navchar.iter().enumerate() {
if *cc == Some(c) {
if self.selected == Some(i) {
return MenuOutcome::Activated(i);
} else {
self.selected = Some(i);
return MenuOutcome::Selected(i);
}
}
}
MenuOutcome::Continue
}
#[inline]
pub fn select_at(&mut self, pos: (u16, u16)) -> bool {
if let Some(idx) = self.mouse.item_at(&self.item_areas, pos.0, pos.1) {
self.selected = Some(idx);
true
} else {
false
}
}
#[inline]
pub fn item_at(&self, pos: (u16, u16)) -> Option<usize> {
self.mouse.item_at(&self.item_areas, pos.0, pos.1)
}
}
impl HandleEvent<crossterm::event::Event, Popup, MenuOutcome> for PopupMenuState {
fn handle(&mut self, event: &crossterm::event::Event, _qualifier: Popup) -> MenuOutcome {
let res = if self.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() {
MenuOutcome::Selected(self.selected.expect("selected"))
} else {
MenuOutcome::Unchanged
}
}
ct_event!(keycode press Down) => {
if self.next_item() {
MenuOutcome::Selected(self.selected.expect("selected"))
} else {
MenuOutcome::Unchanged
}
}
ct_event!(keycode press Home) => {
if self.select(Some(0)) {
MenuOutcome::Selected(self.selected.expect("selected"))
} else {
MenuOutcome::Unchanged
}
}
ct_event!(keycode press End) => {
if self.select(Some(self.len().saturating_sub(1))) {
MenuOutcome::Selected(self.selected.expect("selected"))
} else {
MenuOutcome::Unchanged
}
}
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
}
}
ct_event!(key release _)
| ct_event!(keycode release Up)
| ct_event!(keycode release Down)
| ct_event!(keycode release Home)
| ct_event!(keycode release End)
| ct_event!(keycode release Esc)
| ct_event!(keycode release Enter) => MenuOutcome::Unchanged,
_ => MenuOutcome::Continue,
}
} else {
MenuOutcome::Continue
};
if !res.is_consumed() {
self.handle(event, MouseOnly)
} else {
res
}
}
}
impl HandleEvent<crossterm::event::Event, MouseOnly, MenuOutcome> for PopupMenuState {
fn handle(&mut self, event: &crossterm::event::Event, _: MouseOnly) -> MenuOutcome {
if self.active() {
match event {
ct_event!(mouse moved for col, row) if self.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.area.contains((*col, *row).into()) =>
{
if self.select_at((*col, *row)) {
self.set_active(false);
MenuOutcome::Activated(self.selected().expect("selection"))
} else {
MenuOutcome::Unchanged
}
}
_ => MenuOutcome::Continue,
}
} else {
MenuOutcome::Continue
}
}
}
pub fn handle_popup_events(
state: &mut PopupMenuState,
event: &crossterm::event::Event,
) -> MenuOutcome {
state.handle(event, Popup)
}
pub fn handle_mouse_events(
state: &mut PopupMenuState,
event: &crossterm::event::Event,
) -> MenuOutcome {
state.handle(event, MouseOnly)
}