tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! Error popup widgets.

use ratatui::{
    buffer::Buffer,
    layout::{Alignment, Constraint, Layout, Rect},
    style::{Color, Style},
    text::{Line, Span},
    widgets::{Clear, Paragraph, Widget},
};

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

/// Error popup widget
pub struct ErrorPopup<'a> {
    /// Error message to display
    message: &'a str,
}

impl<'a> ErrorPopup<'a> {
    /// Create new error popup
    #[must_use]
    pub fn new(message: &'a str) -> Self {
        Self { message }
    }
}

impl PopupSizing for ErrorPopup<'_> {
    #[allow(clippy::cast_possible_truncation)] // Safe: clamped to max 20
    fn size_hint(&self) -> SizeHint {
        // Layout: border(2) + padding(1) + message(N) + padding(1) + hint(1) = N + 5
        let line_count = self.message.lines().count().max(1);
        let min_height = (line_count + 5).min(20) as u16;
        SizeHint::percent(0, 0)
            .with_min_width(35)
            .with_min_height(min_height)
    }
}

impl Widget for ErrorPopup<'_> {
    #[allow(clippy::cast_possible_truncation)]
    fn render(self, area: Rect, buf: &mut Buffer) {
        Clear.render(area, buf);

        let block = theme::block(" Error ", BlockVariant::Error);
        let inner = block.inner(area);
        block.render(area, buf);

        // Calculate message line count for layout
        let line_count = self.message.lines().count().max(1) as u16;

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

        // Message (centered)
        Paragraph::new(self.message)
            .style(Style::default().fg(Color::White))
            .alignment(Alignment::Center)
            .render(chunks[1], buf);

        // Hint (centered)
        Paragraph::new("[Enter/Esc] OK")
            .style(Style::default().fg(Color::Gray))
            .alignment(Alignment::Center)
            .render(chunks[3], buf);
    }
}

/// Session terminated popup widget
pub struct SessionTerminatedPopup<'a> {
    /// Session name
    session_name: &'a str,
    /// Exit code if available
    exit_code: Option<i32>,
}

impl<'a> SessionTerminatedPopup<'a> {
    /// Create new session terminated popup
    #[must_use]
    pub fn new(session_name: &'a str, exit_code: Option<i32>) -> Self {
        Self {
            session_name,
            exit_code,
        }
    }
}

impl PopupSizing for SessionTerminatedPopup<'_> {
    fn size_hint(&self) -> SizeHint {
        // Layout: border(2) + padding(1) + content(2) + padding(1) + hint(1) = 7
        SizeHint::percent(0, 0)
            .with_min_width(40)
            .with_min_height(7)
    }
}

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

        let block = theme::block(" Session Terminated ", BlockVariant::Warning);
        let inner = block.inner(area);
        block.render(area, buf);

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

        // Content (centered)
        let exit_text = match self.exit_code {
            Some(code) => format!("Exit code: {code}"),
            None => "Exit code: unknown".to_string(),
        };
        let content = vec![
            Line::from(vec![
                Span::styled("Session: ", Style::default().fg(Color::Gray)),
                Span::styled(self.session_name, Style::default().fg(Color::White)),
            ]),
            Line::from(Span::styled(exit_text, Style::default().fg(Color::Gray))),
        ];
        Paragraph::new(content)
            .alignment(Alignment::Center)
            .render(chunks[1], buf);

        // Hint (centered)
        Paragraph::new("[C] Close session  [K] Keep")
            .style(Style::default().fg(Color::Cyan))
            .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;

    // ErrorPopup tests
    #[test]
    fn error_popup_new() {
        let popup = ErrorPopup::new("test error");
        assert_eq!(popup.message, "test error");
    }

    #[test]
    fn error_popup_renders_message() {
        let popup = ErrorPopup::new("Something went wrong");
        let area = Rect::new(0, 0, 40, 10);
        let mut buf = Buffer::empty(area);

        popup.render(area, &mut buf);

        let output = buffer_to_text(&buf);
        assert!(output.contains("Error"));
        assert!(output.contains("Something went wrong"));
        assert!(output.contains("[Enter/Esc] OK"));
    }

    #[test]
    fn error_popup_renders_small_area() {
        let popup = ErrorPopup::new("test");
        let area = Rect::new(0, 0, 20, 5);
        let mut buf = Buffer::empty(area);

        popup.render(area, &mut buf);

        let output = buffer_to_text(&buf);
        assert!(output.contains("Error"));
    }

    #[test]
    fn error_popup_renders_long_message() {
        let long_msg = "This is a very long error message that should be wrapped to fit within the popup boundaries";
        let popup = ErrorPopup::new(long_msg);
        let area = Rect::new(0, 0, 30, 10);
        let mut buf = Buffer::empty(area);

        popup.render(area, &mut buf);

        let output = buffer_to_text(&buf);
        assert!(output.contains("Error"));
    }

    // SessionTerminatedPopup tests
    #[test]
    fn session_terminated_popup_new() {
        let popup = SessionTerminatedPopup::new("test-session", Some(0));
        assert_eq!(popup.session_name, "test-session");
        assert_eq!(popup.exit_code, Some(0));
    }

    #[test]
    fn session_terminated_popup_renders() {
        let popup = SessionTerminatedPopup::new("test-session", Some(0));
        let area = Rect::new(0, 0, 50, 10);
        let mut buf = Buffer::empty(area);

        popup.render(area, &mut buf);

        let output = buffer_to_text(&buf);
        assert!(output.contains("Session Terminated"));
        assert!(output.contains("Session:"));
        assert!(output.contains("test-session"));
        assert!(output.contains("Exit code: 0"));
        assert!(output.contains("[C] Close session"));
        assert!(output.contains("[K] Keep"));
    }

    #[test]
    fn session_terminated_popup_renders_unknown_exit_code() {
        let popup = SessionTerminatedPopup::new("test-session", None);
        let area = Rect::new(0, 0, 50, 10);
        let mut buf = Buffer::empty(area);

        popup.render(area, &mut buf);

        let output = buffer_to_text(&buf);
        assert!(output.contains("Exit code: unknown"));
    }

    #[test]
    fn session_terminated_popup_renders_small_area() {
        let popup = SessionTerminatedPopup::new("test", Some(1));
        let area = Rect::new(0, 0, 30, 4);
        let mut buf = Buffer::empty(area);

        popup.render(area, &mut buf);

        let output = buffer_to_text(&buf);
        // Small area may not show hint, but should show title
        assert!(output.contains("Session Terminated"));
    }

    mod snapshots {
        use super::*;
        use crate::tui::test_utils::render_to_snapshot;
        use insta::assert_snapshot;

        #[test]
        fn error_popup_short_message() {
            let popup = ErrorPopup::new("Connection failed");
            assert_snapshot!(render_to_snapshot(popup, 40, 8));
        }

        #[test]
        fn session_terminated_with_exit_code() {
            let popup = SessionTerminatedPopup::new("my-session", Some(0));
            assert_snapshot!(render_to_snapshot(popup, 45, 8));
        }

        #[test]
        fn session_terminated_unknown_exit() {
            let popup = SessionTerminatedPopup::new("crashed", None);
            assert_snapshot!(render_to_snapshot(popup, 45, 8));
        }
    }
}