use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Layout, Rect},
text::Line,
style::{Color, Style},
widgets::{
Block, Borders, Clear, Padding, Paragraph, Widget, Wrap,
},
};
use crate::badge::BadgeStack;
use crate::styles::decorate_with_dots_with_pattern;
use crate::styles::BACKGROUND_DOT_SYMBOL;
use crate::styles::DotPattern;
const BORDER_STYLES: Style = Style::new().bold();
const AUTO_WIDTH_PCT: u16 = 80;
const AUTO_HEIGHT_PCT: u16 = 70;
const EMPTY_MSG: &str = "No content";
#[derive(Clone)]
pub struct DotGridConfig {
pub color: Color,
pub symbol: String,
pub density_x: u16,
pub density_y: u16,
pub pattern: DotPattern,
}
impl Default for DotGridConfig {
fn default() -> Self {
Self {
color: Color::DarkGray,
symbol: BACKGROUND_DOT_SYMBOL.to_string(),
density_x: 4,
density_y: 2,
pattern: DotPattern::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PopupSize {
Fixed(u16),
Percent(u16),
Auto,
Max(u16),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BorderType {
Plain,
#[default]
Rounded,
Double,
Thick,
QuadrantInside,
QuadrantOutside,
None,
}
impl BorderType {
pub(crate) fn has_border(&self) -> bool {
!matches!(self, Self::None)
}
pub 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 => panic!("BorderType::None must be filtered before calling to_ratatui"),
}
}
}
#[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<(u16, u16)>,
origin: Option<(u16, u16)>,
header: bool,
alignment: Alignment,
wrap: Wrap,
bg_color: Option<Color>,
dot_grid: Option<DotGridConfig>,
badges: Option<BadgeStack<'a>>,
z_index: u16,
}
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,
alignment: Alignment::Center,
wrap: Wrap { trim: false },
bg_color: None,
dot_grid: Some(DotGridConfig::default()),
badges: None,
z_index: 10,
}
}
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
}
pub fn width(mut self, width: PopupSize) -> Self {
self.width = width;
self
}
pub fn height(mut self, height: PopupSize) -> Self {
self.height = height;
self
}
pub fn border_type(mut self, border_type: BorderType) -> Self {
self.border_type = border_type;
self
}
pub fn padding(mut self, padding: u16) -> Self {
self.padding = padding;
self
}
pub fn empty_message(mut self, msg: &'a str) -> Self {
self.empty_message = Some(msg);
self
}
pub fn position(mut self, x: u16, y: u16) -> Self {
self.position = Some((x, y));
self
}
pub fn origin(mut self, x: u16, y: u16) -> Self {
self.origin = Some((x, y));
self
}
pub fn header(mut self) -> Self {
self.header = true;
self
}
pub fn has_header(&self) -> bool {
self.header
}
pub fn border_color(mut self, color: Color) -> Self {
self.border_color = color;
self
}
pub fn bg_color(mut self, color: Color) -> Self {
self.bg_color = Some(color);
self
}
pub fn no_background(mut self) -> Self {
self.dot_grid = None;
self
}
pub fn background_dots(mut self, color: Color, symbol: &str, density: u16) -> Self {
self.dot_grid = Some(DotGridConfig {
color,
symbol: symbol.to_string(),
density_x: density,
density_y: density,
pattern: DotPattern::default(),
});
self
}
pub fn background_spacing(mut self, density_x: u16, density_y: u16) -> Self {
if let Some(ref mut dg) = self.dot_grid {
dg.density_x = density_x;
dg.density_y = density_y;
} else {
self.dot_grid = Some(DotGridConfig {
density_x,
density_y,
..DotGridConfig::default()
});
}
self
}
pub fn background_pattern(mut self, pattern: DotPattern) -> Self {
if let Some(ref mut dg) = self.dot_grid {
dg.pattern = pattern;
} else {
self.dot_grid = Some(DotGridConfig {
pattern,
..DotGridConfig::default()
});
}
self
}
pub fn badges(mut self, badges: BadgeStack<'a>) -> Self {
self.badges = Some(badges);
self
}
pub fn z_index(mut self, z: u16) -> Self {
self.z_index = z;
self
}
pub(crate) fn get_border_color(&self) -> Color {
self.border_color
}
pub(crate) fn get_height(&self) -> PopupSize {
self.height
}
pub fn alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
pub fn wrap(mut self, wrap: Wrap) -> Self {
self.wrap = wrap;
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),
}
}
pub(crate) 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 {
let border_size: u16 = if self.border_type.has_border() { 1 } else { 0 };
if self.header {
return border_size + 2; }
let extra: u16 = if !self.border_type.has_border() && self.title.is_some() {
1
} else {
0
};
border_size + extra + 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((x, y)) = self.position {
let x = x.min(area.width.saturating_sub(1));
let y = y.min(area.height.saturating_sub(1));
let w = w.min(area.width - x);
let h = h.min(area.height - y);
Rect { x, y, width: w, height: h }
} else if let Some((ox, oy)) = self.origin {
let ox = ox.min(area.width.saturating_sub(1));
let oy = oy.min(area.height.saturating_sub(1));
let w = w.min(area.width - ox);
let h = h.min(area.height - oy);
Rect { x: ox, y: oy, width: w, height: h }
} 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 {
if let Some(ref cfg) = self.dot_grid {
decorate_with_dots_with_pattern(
buf, area, cfg.color, &cfg.symbol, cfg.density_x, cfg.density_y, cfg.pattern,
);
}
let badges_on_top = self
.badges
.as_ref()
.map_or(false, |b| b.max_z_index() >= self.z_index);
if !badges_on_top {
if let Some(ref badges) = self.badges {
badges.render_all(area, buf);
}
}
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 bg_style = self.bg_color.map(|c| Style::new().bg(c));
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 let Some(s) = bg_style {
b = b.style(s);
}
if !self.header {
if let Some(title) = self.title {
b = b.title(title);
}
}
b
} else {
let mut b = Block::default().padding(padding);
if let Some(s) = bg_style {
b = b.style(s);
}
b
};
let inner = block.inner(popup_rect);
block.render(popup_rect, buf);
if badges_on_top {
if let Some(ref badges) = self.badges {
badges.render_all(area, buf);
}
}
if !self.border_type.has_border() && self.title.is_some() && !self.header {
let chunks = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
]).split(inner);
let mut title_style = BORDER_STYLES.fg(self.border_color);
if let Some(bg) = self.bg_color {
title_style = title_style.bg(bg);
}
Paragraph::new(Line::from(self.title.unwrap_or("")))
.style(title_style)
.render(chunks[0], buf);
chunks[1]
} else 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(self.alignment)
.wrap(self.wrap);
para.render(inner, buf);
}
}
#[cfg(test)]
mod tests;