tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! Issue loading popup widget.

use ratatui::{
    buffer::Buffer,
    layout::{Alignment, Constraint, Layout, Rect},
    style::{Color, Style},
    widgets::{Clear, Paragraph, StatefulWidget, Widget},
};
use throbber_widgets_tui::{Throbber, ThrobberState, WhichUse};

use super::{PopupSizing, SizeHint};
use crate::tui::theme::{self, BlockVariant};

/// Issue loading popup widget with spinner.
pub struct IssueLoadingPopup<'a> {
    /// Issue number for title
    issue_number: u32,
    /// Loading message to display
    message: &'a str,
}

impl<'a> IssueLoadingPopup<'a> {
    /// Create new issue loading popup.
    #[must_use]
    pub fn new(issue_number: u32, message: &'a str) -> Self {
        Self {
            issue_number,
            message,
        }
    }
}

impl PopupSizing for IssueLoadingPopup<'_> {
    fn size_hint(&self) -> SizeHint {
        // Static: title + throbber + cancel hint
        SizeHint::percent(0, 0)
            .with_min_width(30)
            .with_min_height(6)
    }
}

impl StatefulWidget for IssueLoadingPopup<'_> {
    type State = ThrobberState;

    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        // Clear background
        Clear.render(area, buf);

        // Render block
        let title = format!(" Issue #{} ", self.issue_number);
        let block = theme::block(&title, BlockVariant::Focused);
        let inner = block.inner(area);
        block.render(area, buf);

        // Layout: padding | throbber | padding | cancel hint
        let chunks = Layout::vertical([
            Constraint::Length(1),
            Constraint::Length(1),
            Constraint::Length(1),
            Constraint::Length(1),
        ])
        .split(inner);

        // Render throbber (centered)
        let throbber = Throbber::default()
            .label(self.message)
            .style(Style::default().fg(Color::Cyan))
            .throbber_style(Style::default().fg(Color::Yellow))
            .use_type(WhichUse::Spin);
        // spinner(1) + space(1) + label (clamped to area width)
        let label_len = u16::try_from(self.message.len()).unwrap_or(u16::MAX);
        let content_width = 2_u16.saturating_add(label_len).min(chunks[1].width);
        let centered_area = Rect {
            x: chunks[1].x + chunks[1].width.saturating_sub(content_width) / 2,
            y: chunks[1].y,
            width: content_width,
            height: 1,
        };
        StatefulWidget::render(throbber, centered_area, buf, state);

        // Render cancel hint
        Paragraph::new("[Esc: cancel]")
            .style(Style::default().fg(Color::DarkGray))
            .alignment(Alignment::Center)
            .render(chunks[3], buf);
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::tui::test_utils::buffer_to_text;

    #[test]
    fn issue_loading_popup_new() {
        let popup = IssueLoadingPopup::new(42, "Loading...");
        assert_eq!(popup.issue_number, 42);
        assert_eq!(popup.message, "Loading...");
    }

    #[test]
    fn issue_loading_popup_size_hint() {
        let popup = IssueLoadingPopup::new(1, "test");
        let hint = popup.size_hint();
        assert_eq!(hint.percent_x, 0);
        assert_eq!(hint.percent_y, 0);
        assert_eq!(hint.min_width, Some(30));
        assert_eq!(hint.min_height, Some(6));
    }

    #[test]
    fn issue_loading_popup_renders() {
        let popup = IssueLoadingPopup::new(42, "Fetching issue...");
        let area = Rect::new(0, 0, 50, 8);
        let mut buf = Buffer::empty(area);
        let mut state = ThrobberState::default();

        popup.render(area, &mut buf, &mut state);

        let output = buffer_to_text(&buf);
        assert!(output.contains("Issue #42"));
        assert!(output.contains("Fetching issue"));
        assert!(output.contains("[Esc: cancel]"));
    }
}