tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! Button row component for confirmation dialogs.
//!
//! Provides a horizontal row of buttons with selection state.

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::Widget,
};

/// Configuration for a single button.
pub struct Button<'a> {
    /// Button label (e.g., "[Y]es", "[N]o")
    pub label: &'a str,
    /// Color when selected
    pub selected_color: Color,
}

impl<'a> Button<'a> {
    /// Create a new button configuration.
    #[must_use]
    pub const fn new(label: &'a str, selected_color: Color) -> Self {
        Self {
            label,
            selected_color,
        }
    }
}

/// A row of buttons for confirmation dialogs.
///
/// Renders buttons horizontally with consistent styling:
/// - Selected button: colored + bold
/// - Unselected button: gray
///
/// # Examples
/// ```ignore
/// let buttons = ButtonRow::new(&[
///     Button::new("[Y]es", Color::Red),
///     Button::new("[N]o", Color::Green),
/// ]).selected(0).centered();  // Yes selected, centered
/// frame.render_widget(buttons, area);
/// ```
pub struct ButtonRow<'a> {
    buttons: &'a [Button<'a>],
    selected: usize,
    centered: bool,
}

impl<'a> ButtonRow<'a> {
    /// Create a new button row.
    ///
    /// # Arguments
    /// - `buttons`: Slice of button configurations
    #[must_use]
    pub const fn new(buttons: &'a [Button<'a>]) -> Self {
        Self {
            buttons,
            selected: 0,
            centered: false,
        }
    }

    /// Set which button is selected (0-indexed).
    #[must_use]
    pub const fn selected(mut self, index: usize) -> Self {
        self.selected = index;
        self
    }

    /// Center the buttons within the area.
    #[must_use]
    pub const fn centered(mut self) -> Self {
        self.centered = true;
        self
    }
}

impl Widget for ButtonRow<'_> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        if area.width == 0 || area.height == 0 {
            return;
        }

        // Build spans
        let mut spans = Vec::new();
        let spacing = "   ";

        for (i, button) in self.buttons.iter().enumerate() {
            if i > 0 {
                spans.push(Span::raw(spacing));
            }
            let style = if i == self.selected {
                Style::default()
                    .fg(button.selected_color)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::Gray)
            };
            spans.push(Span::styled(button.label, style));
        }

        // Calculate total content width
        let content_width: usize = self.buttons.iter().map(|b| b.label.len()).sum::<usize>()
            + spacing.len() * self.buttons.len().saturating_sub(1);

        // Calculate x offset for centering
        #[allow(clippy::cast_possible_truncation)]
        let x = if self.centered {
            let offset = (area.width as usize).saturating_sub(content_width) / 2;
            area.x + offset as u16
        } else {
            area.x + 2 // Original left padding
        };

        let line = Line::from(spans);
        buf.set_line(x, area.y, &line, area.width);
    }
}

/// Preset button configurations for common patterns.
pub mod presets {
    use super::Button;
    use ratatui::style::Color;

    /// Standard Yes button (red when selected).
    pub const YES: Button<'static> = Button::new("[Y]es", Color::Red);

    /// Standard No button (green when selected).
    pub const NO: Button<'static> = Button::new("[N]o", Color::Green);

    /// Extended Yes button with risk acknowledgment.
    pub const YES_UNDERSTAND: Button<'static> =
        Button::new("[Y]es, I understand the risks", Color::Red);

    /// Extended No button with cancel text.
    pub const NO_CANCEL: Button<'static> = Button::new("[N]o, cancel", Color::Green);
}

#[cfg(test)]
mod tests {
    use super::{ButtonRow, presets};
    use crate::tui::test_utils::{buffer_to_text, render_to_snapshot};
    use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};

    #[test]
    fn renders_buttons() {
        let buttons = ButtonRow::new(&[presets::YES, presets::NO]).selected(0);
        let area = Rect::new(0, 0, 30, 1);
        let mut buf = Buffer::empty(area);
        buttons.render(area, &mut buf);

        let text = buffer_to_text(&buf);
        assert!(text.contains("[Y]es"), "Text:\n{text}");
        assert!(text.contains("[N]o"), "Text:\n{text}");
    }

    #[test]
    fn selected_button_has_color() {
        let buttons = ButtonRow::new(&[presets::YES, presets::NO]).selected(0);
        let snapshot = render_to_snapshot(buttons, 30, 1);
        // Yes is selected (index 0), should have Red color [R]
        assert!(
            snapshot.contains("[R+B]"),
            "Selected button should be bold+colored:\n{snapshot}"
        );
    }

    #[test]
    fn unselected_button_is_gray() {
        let buttons = ButtonRow::new(&[presets::YES, presets::NO]).selected(0);
        let snapshot = render_to_snapshot(buttons, 30, 1);
        // No is unselected, should have Gray color [Gy]
        assert!(
            snapshot.contains("[Gy]"),
            "Unselected button should be gray:\n{snapshot}"
        );
    }

    #[test]
    fn second_button_selected() {
        let buttons = ButtonRow::new(&[presets::YES, presets::NO]).selected(1);
        let snapshot = render_to_snapshot(buttons, 30, 1);
        // No is selected (index 1), should have Green color [G]
        assert!(
            snapshot.contains("[G+B]"),
            "Second selected button should be bold+green:\n{snapshot}"
        );
    }

    #[test]
    fn extended_buttons() {
        let buttons = ButtonRow::new(&[presets::YES_UNDERSTAND, presets::NO_CANCEL]).selected(0);
        let area = Rect::new(0, 0, 60, 1);
        let mut buf = Buffer::empty(area);
        buttons.render(area, &mut buf);

        let text = buffer_to_text(&buf);
        assert!(text.contains("I understand the risks"), "Text:\n{text}");
        assert!(text.contains("cancel"), "Text:\n{text}");
    }
}