hefesto-widgets 0.5.2

Ratatui widgets for the Hefesto TUI toolkit: popups, scrollable lists, trees, text input and spinners
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 = "No content";

#[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 {
    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"),
        }
    }
}

/// A configurable popup widget for ratatui.
///
/// Supports absolute positioning via `position(x, y)`, relative positioning
/// via `origin(x, y)`, auto-centering, configurable borders, header mode,
/// background fill via `bg_color`, and customizable text alignment/wrapping.
#[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>,
}

impl<'a> Popup<'a> {
    /// Creates a new `Popup` with the given border color and default settings.
    ///
    /// Defaults: width/height `Auto` (80%/70% of area), border `Rounded`,
    /// padding `1`, alignment `Center`, wrap with `trim: false`, no title,
    /// no content, no header.
    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,
        }
    }

    /// Sets the title displayed in the popup border (non-header mode).
    ///
    /// When `BorderType::None` is set and header mode is off, the title
    /// is rendered as a pseudo-header bar inside the popup content area.
    pub fn title(mut self, title: &'a str) -> Self {
        self.title = Some(title);
        self
    }

    /// Sets the content lines to display inside the popup.
    pub fn content(mut self, content: Vec<Line<'a>>) -> Self {
        self.content = content;
        self
    }

    /// Sets the preferred width: `Fixed(n)`, `Percent(p)`, `Auto`, or
    /// `Max(max)`. When `position` or `origin` is set, the resulting width
    /// is clamped to fit within the remaining area.
    pub fn width(mut self, width: PopupSize) -> Self {
        self.width = width;
        self
    }

    /// Sets the preferred height. See [`width`](Self::width) for sizing
    /// behavior.
    pub fn height(mut self, height: PopupSize) -> Self {
        self.height = height;
        self
    }

    /// Sets the border style. When `BorderType::None`, no border is drawn
    /// and the title (if set and header mode is off) renders as a
    /// pseudo-header bar inside the content area.
    pub fn border_type(mut self, border_type: BorderType) -> Self {
        self.border_type = border_type;
        self
    }

    /// Sets the inner padding between the border and the content.
    pub fn padding(mut self, padding: u16) -> Self {
        self.padding = padding;
        self
    }

    /// Sets a custom message shown when content is empty.
    pub fn empty_message(mut self, msg: &'a str) -> Self {
        self.empty_message = Some(msg);
        self
    }

    /// Places the popup at an absolute `(x, y)` position using the
    /// configured width/height. Coordinates are clamped to the area bounds.
    pub fn position(mut self, x: u16, y: u16) -> Self {
        self.position = Some((x, y));
        self
    }

    /// Places the popup at an `(x, y)` origin using the configured
    /// width/height. Coordinates and size are clamped to the area bounds.
    pub fn origin(mut self, x: u16, y: u16) -> Self {
        self.origin = Some((x, y));
        self
    }

    /// Enables header mode: renders a separate header bar below the border
    /// instead of a border title.
    pub fn header(mut self) -> Self {
        self.header = true;
        self
    }

    /// Sets the border color.
    pub fn border_color(mut self, color: Color) -> Self {
        self.border_color = color;
        self
    }

    /// Sets the background color for the entire popup area, including the
    /// border cells (if any) and the content area.
    ///
    /// When `None` (default), the popup inherits the terminal's background.
    /// The border's `fg` color is preserved on top of the background.
    pub fn bg_color(mut self, color: Color) -> Self {
        self.bg_color = Some(color);
        self
    }

    pub(crate) fn get_border_color(&self) -> Color {
        self.border_color
    }

    pub(crate) fn get_height(&self) -> PopupSize {
        self.height
    }

    /// Sets the text alignment inside the popup (default: `Center`).
    pub fn alignment(mut self, alignment: Alignment) -> Self {
        self.alignment = alignment;
        self
    }

    /// Sets the wrap behavior (default: `Wrap { trim: false }`).
    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<'_> {
    /// Returns the vertical offset from the top of the popup to where content
    /// begins, accounting for the border, header bar, pseudo-header, and padding.
    ///
    /// Useful for consumers that render custom content inside the popup area.
    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; // no padding on top in header mode
        }
        let extra: u16 = if !self.border_type.has_border() && self.title.is_some() {
            1
        } else {
            0
        };
        border_size + extra + self.padding
    }

    /// Resolves the final `Rect` for this popup within the given `area`.
    ///
    /// Priority: `position` > `origin` > centered. Coordinates and size are
    /// clamped to ensure the popup stays within the area bounds.
    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)
        }
    }

    /// Renders a header bar with the given `title` inside `area`.
    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);
    }

    /// Renders the popup chrome (clear, border/block, header) and returns
    /// the inner content area.
    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 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 !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;