dotstate 0.3.3

A modern, secure, and user-friendly dotfile manager built with Rust
Documentation
//! Dialog widget for confirmations, warnings, and errors
//!
//! Provides a self-contained widget that implements the Widget trait.
//! Handles centering, background dimming, borders, and content rendering.

use crate::styles::theme;
use ratatui::layout::Spacing;
use ratatui::prelude::*;
use ratatui::symbols::merge::MergeStrategy;
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap};

/// Dialog variant for different visual styles
#[derive(Debug, Clone, Copy, Default)]
pub enum DialogVariant {
    #[default]
    Default,
    Warning,
    Error,
}

impl DialogVariant {
    /// Get the prefix text for the variant
    fn prefix(&self) -> &'static str {
        match self {
            DialogVariant::Default => "",
            DialogVariant::Warning => "Warning",
            DialogVariant::Error => "Error",
        }
    }
}

/// Dialog widget - a self-contained confirmation/warning/error dialog
pub struct Dialog<'a> {
    /// Title shown in the title block
    pub title: &'a str,
    /// Content text to display
    pub content: &'a str,
    /// Width percentage (0-100), or None to auto-calculate based on content
    pub width_percent: Option<u16>,
    /// Minimum width in columns
    pub min_width: u16,
    /// Maximum width in columns
    pub max_width: u16,
    /// Height percentage (0-100)
    pub height_percent: u16,
    /// Visual variant (affects colors and title prefix)
    pub variant: DialogVariant,
    /// Whether to dim the background behind the dialog
    pub dim_background: bool,
    /// Footer text to display below the dialog (optional)
    pub footer: Option<&'a str>,
    /// Scroll offset for long content
    pub scroll_offset: u16,
}

impl<'a> Dialog<'a> {
    /// Create a new dialog with title and content
    ///
    /// Width is automatically calculated based on content length,
    /// clamped between 50-80 columns by default.
    #[must_use]
    pub fn new(title: &'a str, content: &'a str) -> Self {
        Self {
            title,
            content,
            width_percent: None, // Auto-calculate based on content
            min_width: 60,
            max_width: 80,
            height_percent: 40,
            variant: DialogVariant::Default,
            dim_background: true,
            footer: None,
            scroll_offset: 0,
        }
    }

    /// Set the width as a percentage (0-100)
    /// This overrides auto-width calculation
    #[must_use]
    pub fn width(mut self, percent: u16) -> Self {
        self.width_percent = Some(percent);
        self
    }

    /// Set minimum width in columns (default: 60)
    #[must_use]
    pub fn min_width(mut self, columns: u16) -> Self {
        self.min_width = columns;
        self
    }

    /// Set maximum width in columns (default: 80)
    #[must_use]
    pub fn max_width(mut self, columns: u16) -> Self {
        self.max_width = columns;
        self
    }

    /// Set the height percentage (0-100)
    #[must_use]
    pub fn height(mut self, percent: u16) -> Self {
        self.height_percent = percent;
        self
    }

    /// Set the visual variant (affects border color and title prefix)
    #[must_use]
    pub fn variant(mut self, variant: DialogVariant) -> Self {
        self.variant = variant;
        self
    }

    /// Set whether to dim the background behind the dialog
    #[must_use]
    pub fn dim_background(mut self, dim: bool) -> Self {
        self.dim_background = dim;
        self
    }

    /// Set footer text to display below the dialog
    #[must_use]
    pub fn footer(mut self, footer: &'a str) -> Self {
        self.footer = Some(footer);
        self
    }

    /// Set scroll offset for long content
    #[must_use]
    pub fn scroll(mut self, offset: u16) -> Self {
        self.scroll_offset = offset;
        self
    }

    /// Internal rendering implementation
    fn render_impl(&self, area: Rect, buf: &mut Buffer) {
        let t = theme();

        // Build title with variant prefix first (needed for width calculation)
        let prefix = self.variant.prefix();
        let title_text = if prefix.is_empty() {
            self.title.to_string()
        } else {
            format!("{}: {}", prefix, self.title)
        };

        // Calculate width (auto or percentage-based)
        let modal_width = if let Some(percent) = self.width_percent {
            // Use percentage-based width
            (f32::from(area.width) * (f32::from(percent) / 100.0)) as u16
        } else {
            // Auto-calculate based on content
            let title_len = title_text.len() as u16;
            let footer_len = self.footer.map_or(0, |f| f.len() as u16);

            // Take the longest text, add padding (4 for horizontal padding * 2 sides = 8),
            // borders (2), and some breathing room (10)
            let suggested_width = title_len.max(footer_len) + 20;

            // Clamp between min and max, and don't exceed available width.
            // If the terminal is narrower than `min_width`, fall back to the
            // available width so `clamp` can't be called with `min > max`.
            let max_allowed = self.max_width.min(area.width.saturating_sub(4));
            let min_allowed = self.min_width.min(max_allowed);
            suggested_width.clamp(min_allowed, max_allowed)
        };

        // Calculate minimum required height for the modal
        let has_footer = self.footer.is_some();
        let title_height = 3u16; // 2 borders + 1 text (with horizontal padding only)
        let footer_height = 3u16; // 2 borders + 1 text (with horizontal padding only)
        let min_content_height = 5u16; // Minimum content height

        // Total minimum height accounting for collapsed borders (each collapse saves 1 line)
        let min_total_height = if has_footer {
            title_height + min_content_height + footer_height - 2 // -2 for two collapsed borders
        } else {
            title_height + min_content_height - 1 // -1 for one collapsed border
        };

        // Calculate modal height
        let modal_height =
            (f32::from(area.height) * (f32::from(self.height_percent) / 100.0)) as u16;
        let modal_height = modal_height
            .max(min_total_height)
            .min(area.height.saturating_sub(2));

        // Center the modal
        let popup_x = area.x + (area.width.saturating_sub(modal_width)) / 2;
        let popup_y = area.y + (area.height.saturating_sub(modal_height)) / 2;
        let popup_area = Rect::new(popup_x, popup_y, modal_width, modal_height);
        // Optionally dim the background
        if self.dim_background {
            // Dim the entire background (page content becomes darker)
            let dim = Block::default().style(t.dim_style());
            Widget::render(dim, area, buf);
        }

        // Always clear the dialog area for clean rendering
        Widget::render(Clear, popup_area, buf);

        // Determine border style based on variant
        let border_style = match self.variant {
            DialogVariant::Default => Style::default().fg(t.border_focused),
            DialogVariant::Warning => Style::default().fg(t.warning),
            DialogVariant::Error => Style::default().fg(t.error),
        };

        // Create vertical layout with collapsed borders (web dialog style)
        // Three blocks: title, content, footer
        let constraints = if has_footer {
            vec![
                Constraint::Length(title_height),
                Constraint::Min(min_content_height), // Content block (flexible)
                Constraint::Length(footer_height),
            ]
        } else {
            vec![
                Constraint::Length(title_height),
                Constraint::Min(min_content_height), // Content block (flexible)
            ]
        };

        let layout = Layout::vertical(constraints)
            .spacing(Spacing::Overlap(1)) // Collapse borders
            .split(popup_area);

        let border_type = t.dialog_border_type;

        // Title block (top) - use horizontal padding only to save vertical space
        let title_block = Block::default()
            .borders(Borders::ALL)
            .border_type(border_type)
            .border_style(border_style)
            .padding(Padding::horizontal(2))
            .merge_borders(MergeStrategy::Exact)
            .style(t.background_style());

        let title_inner = title_block.inner(layout[0]);
        Widget::render(title_block, layout[0], buf);

        // Render title text as centered paragraph
        let title_para = Paragraph::new(title_text)
            .alignment(Alignment::Center)
            .style(t.text_style().add_modifier(Modifier::BOLD));
        Widget::render(title_para, title_inner, buf);

        // Content block (middle) - use horizontal padding only to maximize content space
        let content_block = Block::default()
            .borders(Borders::ALL)
            .border_type(border_type)
            .border_style(border_style)
            .padding(Padding::horizontal(2))
            .merge_borders(MergeStrategy::Exact)
            .style(t.background_style());

        let content_inner = content_block.inner(layout[1]);
        Widget::render(content_block, layout[1], buf);

        // Clamp scroll offset to prevent scrolling past content
        // Estimate total wrapped lines based on content and available width
        let content_width = content_inner.width.saturating_sub(1) as usize;
        let total_wrapped_lines = if content_width > 0 {
            self.content
                .lines()
                .map(|line| {
                    let len = line.len();
                    if len == 0 {
                        1
                    } else {
                        len.div_ceil(content_width)
                    }
                })
                .sum::<usize>()
        } else {
            self.content.lines().count()
        };
        let visible_lines = content_inner.height as usize;
        let max_scroll = total_wrapped_lines.saturating_sub(visible_lines);
        let clamped_scroll = (self.scroll_offset as usize).min(max_scroll) as u16;

        // Render content text (left-aligned, wrapped, with scrolling)
        let content_para = Paragraph::new(self.content)
            .wrap(Wrap { trim: true })
            .alignment(Alignment::Left)
            .style(t.text_style())
            .scroll((clamped_scroll, 0));
        Widget::render(content_para, content_inner, buf);

        // Footer block (bottom) - optional
        if has_footer {
            let footer_block = Block::default()
                .borders(Borders::ALL)
                .border_type(border_type)
                .border_style(border_style)
                .padding(Padding::horizontal(2))
                .merge_borders(MergeStrategy::Exact)
                .style(t.background_style());

            let footer_inner = footer_block.inner(layout[2]);
            Widget::render(footer_block, layout[2], buf);

            // Render footer text
            if let Some(footer_text) = self.footer {
                let footer_para = Paragraph::new(footer_text)
                    .alignment(Alignment::Center)
                    .style(t.text_style().add_modifier(Modifier::BOLD));
                Widget::render(footer_para, footer_inner, buf);
            }
        }
    }
}

impl Widget for Dialog<'_> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        self.render_impl(area, buf);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn render_at(width: u16, height: u16) {
        let area = Rect::new(0, 0, width, height);
        let mut buf = Buffer::empty(area);
        Dialog::new("Sync with Remote", "Pushing changes to origin/main")
            .footer("Enter: confirm  Esc: cancel")
            .render(area, &mut buf);
    }

    #[test]
    fn renders_in_narrow_terminal_without_panic() {
        // Regression for GitHub issue #52: clamp(60, 51) used to panic.
        render_at(55, 24);
        render_at(40, 20);
        render_at(10, 10);
    }

    #[test]
    fn renders_at_normal_size() {
        render_at(120, 40);
    }
}