hefesto-widgets 0.6.5

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},
    style::{Color, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph, StatefulWidget, Widget},
};

use crate::popup::{Popup, PopupSize};
use crate::scroll_list::ScrollList;
use crate::spin::SpinState;
use crate::spins::SpinVariant;
use crate::{
    BorderType, BORDER_GRAY, CHECK, CROSS, DotPattern, OUTPUT_GRAY, TEXT_PADDING,
};

fn truncate(text: &str, max_width: usize) -> String {
    let chars: Vec<char> = text.chars().collect();
    if chars.len() <= max_width || max_width == 0 {
        text.to_string()
    } else if max_width >= 4 {
        let truncated: String = chars[..max_width - 3].iter().collect();
        format!("{}...", truncated)
    } else {
        chars[..max_width].iter().collect()
    }
}

#[derive(Clone)]
pub struct SpinPopup<'a> {
    popup: Popup<'a>,
    variant: SpinVariant,
    title: Option<&'a str>,
    command: Option<&'a str>,
    output_lines: Vec<&'a str>,
    finished_msg: Option<&'a str>,
    max_output_rows: u16,
    spinner_style: Style,
    command_style: Style,
    output_style: Style,
}

impl<'a> SpinPopup<'a> {
    pub fn new() -> Self {
        Self {
            popup: Popup::new(Color::White)
                .padding(0)
                .border_type(BorderType::Rounded),
            variant: SpinVariant::default(),
            title: None,
            command: None,
            output_lines: vec![],
            finished_msg: Some("Presione Enter para continuar"),
            max_output_rows: 10,
            spinner_style: Style::new().fg(Color::Blue),
            command_style: Style::new().fg(Color::White),
            output_style: Style::new().fg(OUTPUT_GRAY),
        }
    }

    pub fn title(mut self, title: &'a str) -> Self {
        self.title = Some(title);
        self
    }

    pub fn command(mut self, command: &'a str) -> Self {
        self.command = Some(command);
        self
    }

    pub fn output_lines(mut self, lines: &'a [&'a str]) -> Self {
        self.output_lines = lines.to_vec();
        self
    }

    pub fn variant(mut self, variant: SpinVariant) -> Self {
        self.variant = variant;
        self
    }

    pub fn finished_msg(mut self, msg: &'a str) -> Self {
        self.finished_msg = Some(msg);
        self
    }

    pub fn no_finished_msg(mut self) -> Self {
        self.finished_msg = None;
        self
    }

    pub fn max_output_rows(mut self, rows: u16) -> Self {
        self.max_output_rows = rows;
        self
    }

    pub fn spinner_style(mut self, style: Style) -> Self {
        self.spinner_style = style;
        self
    }

    pub fn command_style(mut self, style: Style) -> Self {
        self.command_style = style;
        self
    }

    pub fn output_style(mut self, style: Style) -> Self {
        self.output_style = style;
        self
    }

    pub fn border_color(mut self, color: Color) -> Self {
        self.popup = self.popup.border_color(color);
        self
    }

    pub fn border_type(mut self, bt: BorderType) -> Self {
        self.popup = self.popup.border_type(bt);
        self
    }

    /// Background color for the entire popup area (including border).
    /// Delegates to `Popup::bg_color`.
    pub fn bg_color(mut self, color: Color) -> Self {
        self.popup = self.popup.bg_color(color);
        self
    }

    pub fn width(mut self, w: PopupSize) -> Self {
        self.popup = self.popup.width(w);
        self
    }

    pub fn height(mut self, h: PopupSize) -> Self {
        self.popup = self.popup.height(h);
        self
    }

    pub fn position(mut self, x: u16, y: u16) -> Self {
        self.popup = self.popup.position(x, y);
        self
    }

    pub fn origin(mut self, x: u16, y: u16) -> Self {
        self.popup = self.popup.origin(x, y);
        self
    }

    pub fn header(mut self) -> Self {
        self.popup = self.popup.header();
        self
    }

    pub fn padding(mut self, p: u16) -> Self {
        self.popup = self.popup.padding(p);
        self
    }

    pub fn resolve_rect(&self, area: Rect) -> Rect {
        self.popup.resolve_rect(area)
    }

    pub fn no_background(mut self) -> Self {
        self.popup = self.popup.no_background();
        self
    }

    pub fn background_dots(mut self, color: Color, symbol: &str, density: u16) -> Self {
        self.popup = self.popup.background_dots(color, symbol, density);
        self
    }

    pub fn background_pattern(mut self, pattern: DotPattern) -> Self {
        self.popup = self.popup.background_pattern(pattern);
        self
    }

    pub fn background_spacing(mut self, density_x: u16, density_y: u16) -> Self {
        self.popup = self.popup.background_spacing(density_x, density_y);
        self
    }
}

impl StatefulWidget for SpinPopup<'_> {
    type State = SpinState;

    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        let show_footer = state.finished && self.finished_msg.is_some();

        let content_h = {
            let mut h = 2u16; // spin + title text(1) + bottom border(1)
            if self.command.is_some() {
                h += 1; // just text, no border
            }
            if !self.output_lines.is_empty() {
                h += self.output_lines.len().min(self.max_output_rows as usize) as u16;
                h += 1; // log box top border
            }
            if show_footer {
                h += 2; // text(1) + bottom border(1)
            }
            h
        };

        let total_height = content_h + 2; // popup borders(2)
        let mut popup = self.popup;
        if popup.get_height() == PopupSize::Auto {
            popup = popup.height(PopupSize::Fixed(total_height));
        }
        let inner = popup.render_inner(area, buf);

        let frames = self.variant.frames();
        let (ch, spin_style) = if state.finished {
            let ok = state.exit_code.map_or(true, |c| c == 0);
            (if ok { CHECK } else { CROSS }, Style::new().fg(if ok { Color::Green } else { Color::Red }))
        } else {
            let frame = if frames.is_empty() {
                " "
            } else {
                frames[state.frame % frames.len()]
            };
            (frame, self.spinner_style)
        };

        let mut constraints: Vec<Constraint> = vec![Constraint::Length(2)];

        if self.command.is_some() {
            constraints.push(Constraint::Length(1));
        }

        if !self.output_lines.is_empty() {
            constraints.push(Constraint::Length(
                self.output_lines.len().min(self.max_output_rows as usize) as u16 + 1,
            ));
        }

        if show_footer {
            constraints.push(Constraint::Length(2));
        }

        let chunks = Layout::vertical(constraints).split(inner);

        let mut idx = 0usize;

        let spin_block = Block::bordered()
            .borders(Borders::BOTTOM)
            .border_style(Style::new().fg(BORDER_GRAY))
            .padding(TEXT_PADDING);
        let spin_inner = spin_block.inner(chunks[idx]);
        spin_block.render(chunks[idx], buf);

        let mut title_spans = vec![Span::styled(ch.to_string(), spin_style)];
        if let Some(title) = self.title {
            let title_max = (spin_inner.width.saturating_sub(2)) as usize;
            let display = truncate(title, title_max);
            title_spans.push(Span::styled(
                format!(" {}", display),
                Style::new().fg(Color::White).bold(),
            ));
        }
        Paragraph::new(Line::from(title_spans)).render(spin_inner, buf);
        idx += 1;

        if self.command.is_some() {
            let cmd_max = chunks[idx].width as usize;
            Paragraph::new(Line::from(Span::styled(
                truncate(self.command.unwrap(), cmd_max),
                self.command_style,
            )))
            .alignment(Alignment::Center)
            .render(chunks[idx], buf);
            idx += 1;
        }

        if !self.output_lines.is_empty() {
            let lines: Vec<String> = self.output_lines.into_iter().map(|s| s.to_string()).collect();
            ScrollList::new(lines)
                .style(self.output_style)
                .borders(Borders::TOP)
                .border_style(Style::new().fg(BORDER_GRAY))
                .render(chunks[idx], buf, &mut state.scroll_list);
            idx += 1;
        }

        if show_footer {
            let footer_block = Block::bordered()
                .borders(Borders::TOP)
                .border_style(Style::new().fg(BORDER_GRAY))
                .padding(TEXT_PADDING);
            let footer_inner = footer_block.inner(chunks[idx]);
            footer_block.render(chunks[idx], buf);
            let footer_max = footer_inner.width as usize;
            Paragraph::new(Line::from(Span::styled(
                truncate(self.finished_msg.unwrap(), footer_max),
                Style::new().fg(Color::Green),
            )))
            .alignment(Alignment::Center)
            .render(footer_inner, buf);
        }
    }
}

#[cfg(test)]
mod tests;