use std::collections::{BTreeMap, HashSet};
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::{Color, Style},
text::Line,
widgets::{Block, Clear, Padding, Paragraph, StatefulWidget, Widget, Wrap},
};
use crate::popup::PopupSize;
use crate::BorderType;
const CLOSE_SYMBOL: &str = "×";
const BADGE_GAP_X: u16 = 1;
const BADGE_GAP_Y: u16 = 0;
const BORDER_STYLES: Style = Style::new().bold();
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
pub enum BadgeAnchor {
#[default]
TopLeft,
Top,
TopRight,
BottomLeft,
Bottom,
BottomRight,
Left,
Right,
}
#[derive(Clone)]
pub struct Badge<'a> {
width: PopupSize,
height: PopupSize,
border_color: Color,
border_type: BorderType,
padding: u16,
title: Option<&'a str>,
content: Vec<Line<'a>>,
anchor: BadgeAnchor,
closable: bool,
bg_color: Option<Color>,
style: Style,
alignment: Alignment,
z_index: u16,
layer: u16,
}
impl<'a> Badge<'a> {
pub fn new(content: &'a str, border_color: Color) -> Self {
Self {
width: PopupSize::Fixed(20),
height: PopupSize::Fixed(3),
border_color,
border_type: BorderType::Rounded,
padding: 0,
title: None,
content: vec![Line::from(content)],
anchor: BadgeAnchor::default(),
closable: true,
bg_color: None,
style: Style::default(),
alignment: Alignment::Left,
z_index: 5,
layer: 0,
}
}
pub fn with_content(content: Vec<Line<'a>>, border_color: Color) -> Self {
Self {
content,
..Self::new("", border_color)
}
}
pub fn anchor(mut self, anchor: BadgeAnchor) -> Self {
self.anchor = anchor;
self
}
pub fn alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
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 closable(mut self, closable: bool) -> Self {
self.closable = closable;
self
}
pub fn title(mut self, title: &'a str) -> Self {
self.title = Some(title);
self
}
pub fn border_type(mut self, bt: BorderType) -> Self {
self.border_type = bt;
self
}
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 padding(mut self, padding: u16) -> Self {
self.padding = padding;
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn z_index(mut self, z: u16) -> Self {
self.z_index = z;
self
}
pub fn layer(mut self, layer: u16) -> Self {
self.layer = layer;
self
}
fn resolve(&self, available: u16, size: PopupSize) -> u16 {
match size {
PopupSize::Fixed(v) => v,
PopupSize::Percent(p) => available * p / 100,
PopupSize::Auto => available * 80 / 100,
PopupSize::Max(max) => (available * 80 / 100).min(max),
}
}
fn resolve_width(&self, area_width: u16) -> u16 {
self.resolve(area_width, self.width)
}
fn resolve_height(&self, area_height: u16) -> u16 {
self.resolve(area_height, self.height)
}
fn render_at(self, rect: Rect, buf: &mut Buffer) {
Clear.render(rect, buf);
let bg_style = self.bg_color.map(|c| Style::new().bg(c));
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::new(
self.padding,
self.padding,
self.padding,
self.padding,
));
if let Some(s) = bg_style {
b = b.style(s);
}
if let Some(t) = self.title {
b = b.title_top(Line::from(t).left_aligned());
}
if self.closable {
b = b.title_top(Line::from(CLOSE_SYMBOL).right_aligned());
}
let inner = b.inner(rect);
b.render(rect, buf);
Paragraph::new(self.content)
.style(self.style)
.alignment(self.alignment)
.wrap(Wrap { trim: false })
.render(inner, buf);
} else {
let mut b = Block::default().padding(Padding::new(
self.padding,
self.padding,
self.padding,
self.padding,
));
if let Some(s) = bg_style {
b = b.style(s);
}
let inner = b.inner(rect);
b.render(rect, buf);
Paragraph::new(self.content)
.style(self.style)
.alignment(self.alignment)
.wrap(Wrap { trim: false })
.render(inner, buf);
}
}
}
#[derive(Clone, Default)]
pub struct BadgeStack<'a> {
badges: Vec<Badge<'a>>,
}
impl<'a> BadgeStack<'a> {
pub fn new() -> Self {
Self { badges: Vec::new() }
}
pub fn push(mut self, badge: Badge<'a>) -> Self {
self.badges.push(badge);
self
}
pub(crate) fn max_z_index(&self) -> u16 {
self.badges.iter().map(|b| b.z_index).max().unwrap_or(0)
}
pub fn render_all(&self, area: Rect, buf: &mut Buffer) {
let mut grouped: BTreeMap<(BadgeAnchor, u16), Vec<&Badge<'_>>> = BTreeMap::new();
for badge in &self.badges {
grouped.entry((badge.anchor, badge.layer)).or_default().push(badge);
}
let mut by_anchor: BTreeMap<BadgeAnchor, Vec<(u16, Vec<&Badge<'_>>)>> = BTreeMap::new();
for ((anchor, layer), badges) in grouped {
by_anchor.entry(anchor).or_default().push((layer, badges));
}
for layers in by_anchor.values_mut() {
layers.sort_by_key(|(l, _)| *l);
}
for (anchor, layers) in by_anchor {
let mut offset: u16 = 0;
for (_layer, mut badges) in layers {
badges.sort_by_key(|b| b.z_index);
let sizes: Vec<(u16, u16)> = badges
.iter()
.map(|b| (b.resolve_width(area.width), b.resolve_height(area.height)))
.collect();
let rects = group_rects(area, anchor, &sizes, offset);
let max_dim = match anchor {
BadgeAnchor::TopLeft
| BadgeAnchor::Top
| BadgeAnchor::TopRight
| BadgeAnchor::BottomLeft
| BadgeAnchor::Bottom
| BadgeAnchor::BottomRight => {
sizes.iter().map(|(_, h)| *h).max().unwrap_or(0)
}
BadgeAnchor::Left | BadgeAnchor::Right => {
sizes.iter().map(|(w, _)| *w).max().unwrap_or(0)
}
};
offset += max_dim + BADGE_GAP_Y;
for (badge, rect) in badges.into_iter().zip(rects) {
badge.clone().render_at(rect, buf);
}
}
}
}
}
#[derive(Default)]
pub struct BadgeStackState {
dismissed: HashSet<usize>,
}
impl BadgeStackState {
pub fn dismiss(&mut self, index: usize) {
self.dismissed.insert(index);
}
pub fn is_visible(&self, index: usize) -> bool {
!self.dismissed.contains(&index)
}
pub fn reset(&mut self) {
self.dismissed.clear();
}
}
fn group_rects(area: Rect, anchor: BadgeAnchor, sizes: &[(u16, u16)], layer_offset: u16) -> Vec<Rect> {
let gap = BADGE_GAP_X;
let max_w = area.width;
let max_h = area.height;
if sizes.is_empty() {
return Vec::new();
}
match anchor {
BadgeAnchor::TopLeft
| BadgeAnchor::Top
| BadgeAnchor::Bottom
| BadgeAnchor::BottomLeft => {
let y0 = match anchor {
BadgeAnchor::TopLeft | BadgeAnchor::Top => layer_offset,
BadgeAnchor::BottomLeft | BadgeAnchor::Bottom => {
let max_badge_h = sizes.iter().map(|(_, h)| *h).max().unwrap_or(0);
max_h.saturating_sub(max_badge_h + layer_offset)
}
_ => unreachable!(),
};
let total_w: u16 = sizes
.iter()
.map(|(w, _)| w + gap)
.sum::<u16>()
.saturating_sub(gap);
let start_x = match anchor {
BadgeAnchor::TopLeft | BadgeAnchor::BottomLeft => 0,
BadgeAnchor::Top | BadgeAnchor::Bottom => max_w.saturating_sub(total_w) / 2,
_ => unreachable!(),
};
let mut x = start_x;
sizes
.iter()
.map(|(w, h)| {
let rect = Rect {
x: x.min(max_w.saturating_sub(1)),
y: y0,
width: (*w).min(max_w.saturating_sub(x)),
height: (*h).min(max_h.saturating_sub(y0)),
};
x += w + gap;
rect
})
.collect()
}
BadgeAnchor::TopRight | BadgeAnchor::BottomRight => {
let y0 = match anchor {
BadgeAnchor::TopRight => layer_offset,
BadgeAnchor::BottomRight => {
let max_badge_h = sizes.iter().map(|(_, h)| *h).max().unwrap_or(0);
max_h.saturating_sub(max_badge_h + layer_offset)
}
_ => unreachable!(),
};
let total_w: u16 = sizes
.iter()
.map(|(w, _)| w + gap)
.sum::<u16>()
.saturating_sub(gap);
let start_x = max_w.saturating_sub(total_w);
let mut x = start_x;
sizes
.iter()
.map(|(w, h)| {
let rect = Rect {
x: x.min(max_w.saturating_sub(1)),
y: y0,
width: (*w).min(max_w.saturating_sub(x)),
height: (*h).min(max_h.saturating_sub(y0)),
};
x += w + gap;
rect
})
.collect()
}
BadgeAnchor::Left | BadgeAnchor::Right => {
let x0 = match anchor {
BadgeAnchor::Left => layer_offset,
BadgeAnchor::Right => {
let max_badge_w = sizes.iter().map(|(w, _)| *w).max().unwrap_or(0);
max_w.saturating_sub(max_badge_w + layer_offset)
}
_ => unreachable!(),
};
let total_h: u16 = sizes
.iter()
.map(|(_, h)| h + gap)
.sum::<u16>()
.saturating_sub(gap);
let start_y = max_h.saturating_sub(total_h) / 2;
let mut y = start_y;
sizes
.iter()
.map(|(w, h)| {
let rect = Rect {
x: x0,
y: y.min(max_h.saturating_sub(1)),
width: (*w).min(max_w.saturating_sub(x0)),
height: (*h).min(max_h.saturating_sub(y)),
};
y += h + gap;
rect
})
.collect()
}
}
}
impl StatefulWidget for BadgeStack<'_> {
type State = BadgeStackState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let mut grouped: BTreeMap<(BadgeAnchor, u16), Vec<Badge<'_>>> = BTreeMap::new();
for (i, badge) in self.badges.into_iter().enumerate() {
if state.is_visible(i) {
grouped.entry((badge.anchor, badge.layer)).or_default().push(badge);
}
}
let mut by_anchor: BTreeMap<BadgeAnchor, Vec<(u16, Vec<Badge<'_>>)>> = BTreeMap::new();
for ((anchor, layer), badges) in grouped {
by_anchor.entry(anchor).or_default().push((layer, badges));
}
for layers in by_anchor.values_mut() {
layers.sort_by_key(|(l, _)| *l);
}
for (anchor, layers) in by_anchor {
let mut offset: u16 = 0;
for (_layer, mut badges) in layers {
badges.sort_by_key(|b| b.z_index);
let sizes: Vec<(u16, u16)> = badges
.iter()
.map(|b| (b.resolve_width(area.width), b.resolve_height(area.height)))
.collect();
let rects = group_rects(area, anchor, &sizes, offset);
let max_dim = match anchor {
BadgeAnchor::TopLeft
| BadgeAnchor::Top
| BadgeAnchor::TopRight
| BadgeAnchor::BottomLeft
| BadgeAnchor::Bottom
| BadgeAnchor::BottomRight => {
sizes.iter().map(|(_, h)| *h).max().unwrap_or(0)
}
BadgeAnchor::Left | BadgeAnchor::Right => {
sizes.iter().map(|(w, _)| *w).max().unwrap_or(0)
}
};
offset += max_dim + BADGE_GAP_Y;
for (badge, rect) in badges.into_iter().zip(rects) {
badge.render_at(rect, buf);
}
}
}
}
}
#[cfg(test)]
mod tests;