hefesto-widgets 0.3.0

Ratatui widgets for the Hefesto TUI toolkit
Documentation
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 // border + header
        } else {
            1 + self.padding // border + top 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);
    }
}