use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Rect},
text::Line,
style::{Color, Style},
widgets::{
Block, Borders, Clear, Padding, Paragraph, Widget, Wrap,
},
};
const BORDER_STYLES: Style = Style::new().bold();
const AUTO_WIDTH_PCT: u16 = 80;
const AUTO_HEIGHT_PCT: u16 = 70;
const EMPTY_MSG: &str = "Sin contenido para mostrar";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum PopupSize {
Fixed(u16),
Percent(u16),
Auto,
Max(u16),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[allow(dead_code)]
pub enum BorderType {
Plain,
Rounded,
Double,
Thick,
QuadrantInside,
QuadrantOutside,
#[default]
None,
}
impl BorderType {
fn has_border(&self) -> bool {
!matches!(self, Self::None)
}
fn to_ratatui(&self) -> ratatui::widgets::BorderType {
match self {
Self::Plain => ratatui::widgets::BorderType::Plain,
Self::Rounded => ratatui::widgets::BorderType::Rounded,
Self::Double => ratatui::widgets::BorderType::Double,
Self::Thick => ratatui::widgets::BorderType::Thick,
Self::QuadrantInside => ratatui::widgets::BorderType::QuadrantInside,
Self::QuadrantOutside => ratatui::widgets::BorderType::QuadrantOutside,
Self::None => unreachable!(),
}
}
}
#[derive(Clone)]
pub struct Popup<'a> {
width: PopupSize,
height: PopupSize,
border_color: Color,
border_type: BorderType,
padding: u16,
title: Option<&'a str>,
content: Vec<Line<'a>>,
empty_message: Option<&'a str>,
position: Option<Rect>,
origin: Option<(u16, u16)>,
header: bool,
}
#[allow(dead_code)]
impl<'a> Popup<'a> {
pub fn new(border_color: Color) -> Self {
Self {
width: PopupSize::Auto,
height: PopupSize::Auto,
border_color,
border_type: BorderType::Rounded,
padding: 1,
title: None,
content: vec![],
empty_message: None,
position: None,
origin: None,
header: false,
}
}
pub fn title(mut self, title: &'a str) -> Self {
self.title = Some(title);
self
}
pub fn content(mut self, content: Vec<Line<'a>>) -> Self {
self.content = content;
self
}
#[allow(dead_code)]
pub fn width(mut self, width: PopupSize) -> Self {
self.width = width;
self
}
#[allow(dead_code)]
pub fn height(mut self, height: PopupSize) -> Self {
self.height = height;
self
}
#[allow(dead_code)]
pub fn border_type(mut self, border_type: BorderType) -> Self {
self.border_type = border_type;
self
}
#[allow(dead_code)]
pub fn padding(mut self, padding: u16) -> Self {
self.padding = padding;
self
}
#[allow(dead_code)]
pub fn empty_message(mut self, msg: &'a str) -> Self {
self.empty_message = Some(msg);
self
}
#[allow(dead_code)]
pub fn position(mut self, position: Rect) -> Self {
self.position = Some(position);
self
}
#[allow(dead_code)]
pub fn origin(mut self, x: u16, y: u16) -> Self {
self.origin = Some((x, y));
self
}
#[allow(dead_code)]
pub fn header(mut self) -> Self {
self.header = true;
self
}
#[allow(dead_code)]
pub fn border_color(mut self, color: Color) -> Self {
self.border_color = color;
self
}
fn resolve(&self, available: u16) -> u16 {
match self.width {
PopupSize::Fixed(v) => v,
PopupSize::Percent(p) => available * p / 100,
PopupSize::Auto => available * AUTO_WIDTH_PCT / 100,
PopupSize::Max(max) => (available * AUTO_WIDTH_PCT / 100).min(max),
}
}
fn resolve_height(&self, available: u16) -> u16 {
match self.height {
PopupSize::Fixed(v) => v,
PopupSize::Percent(p) => available * p / 100,
PopupSize::Auto => available * AUTO_HEIGHT_PCT / 100,
PopupSize::Max(max) => (available * AUTO_HEIGHT_PCT / 100).min(max),
}
}
fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
Rect {
x: (area.width.saturating_sub(width)) / 2,
y: (area.height.saturating_sub(height)) / 2,
width: width.min(area.width),
height: height.min(area.height),
}
}
}
impl Popup<'_> {
pub fn content_offset(&self) -> u16 {
if self.header {
1 + 2 } else {
1 + self.padding }
}
pub fn resolve_rect(&self, area: Rect) -> Rect {
let w = self.resolve(area.width);
let h = self.resolve_height(area.height);
if let Some(pos) = self.position {
pos
} else if let Some((ox, oy)) = self.origin {
Rect { x: ox, y: oy, width: w.min(area.width), height: h.min(area.height) }
} else {
Self::centered_rect(area, w, h)
}
}
pub fn render_header(area: Rect, buf: &mut Buffer, border_color: Color, title: &str) {
let block = Block::bordered()
.borders(Borders::BOTTOM)
.border_style(BORDER_STYLES.fg(border_color))
.padding(Padding::new(1, 1, 0, 0));
let inner = block.inner(area);
let para = Paragraph::new(title);
para.render(inner, buf);
block.render(area, buf);
}
pub fn render_inner(&self, area: Rect, buf: &mut Buffer) -> Rect {
let popup_rect = self.resolve_rect(area);
Clear.render(popup_rect, buf);
let padding = if self.header {
Padding::new(self.padding, self.padding, 0, self.padding)
} else {
Padding::new(self.padding, self.padding, self.padding, self.padding)
};
let block = if self.border_type.has_border() {
let mut b = Block::bordered()
.border_type(self.border_type.to_ratatui())
.border_style(BORDER_STYLES.fg(self.border_color))
.padding(padding);
if !self.header {
if let Some(title) = self.title {
b = b.title(title);
}
}
b
} else {
Block::default().padding(padding)
};
let inner = block.inner(popup_rect);
block.render(popup_rect, buf);
if self.header {
let chunks = Layout::vertical([
Constraint::Length(2),
Constraint::Min(0),
]).split(inner);
Self::render_header(
chunks[0], buf,
self.border_color,
self.title.unwrap_or(""),
);
chunks[1]
} else {
inner
}
}
}
impl Widget for Popup<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let inner = self.render_inner(area, buf);
let display: Vec<Line<'_>> = if self.content.is_empty() {
let msg = self.empty_message.unwrap_or(EMPTY_MSG);
vec![Line::from(msg)]
} else {
self.content
};
let para = Paragraph::new(display)
.alignment(Alignment::Center)
.wrap(Wrap { trim: false });
para.render(inner, buf);
}
}