ballin 0.1.2

A colorful interactive physics simulator with thousands of balls, but in your terminal.
Documentation
//! Shape selection menu popup.
//!
//! Displays a 3x2 grid of available shapes for the user to select.
//! When a shape is selected, it is placed randomly in the simulation.

use ratatui::{
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Clear, Paragraph},
    Frame,
};

use crate::shapes::ShapeType;

#[derive(Debug, Clone)]
pub struct ShapeMenu {
    pub visible: bool,
    selected_index: usize,
}

impl Default for ShapeMenu {
    fn default() -> Self {
        Self::new()
    }
}

impl ShapeMenu {
    pub fn new() -> Self {
        Self {
            visible: false,
            selected_index: 0,
        }
    }

    pub fn show(&mut self) {
        self.visible = true;
    }

    pub fn hide(&mut self) {
        self.visible = false;
    }

    pub fn toggle(&mut self) {
        self.visible = !self.visible;
    }

    pub fn selected_shape_type(&self) -> ShapeType {
        ShapeType::from_grid_index(self.selected_index).unwrap_or(ShapeType::Circle)
    }

    pub fn selected_index(&self) -> usize {
        self.selected_index
    }

    pub fn select_up(&mut self) {
        if self.selected_index >= 3 {
            self.selected_index -= 3;
        }
    }

    pub fn select_down(&mut self) {
        if self.selected_index < 3 {
            self.selected_index += 3;
        }
    }

    pub fn select_left(&mut self) {
        if !self.selected_index.is_multiple_of(3) {
            self.selected_index -= 1;
        }
    }

    pub fn select_right(&mut self) {
        if self.selected_index % 3 < 2 {
            self.selected_index += 1;
        }
    }

    pub fn set_selected(&mut self, index: usize) {
        if index < 6 {
            self.selected_index = index;
        }
    }

    /// Returns the selected shape type if a shape cell was clicked.
    pub fn handle_click(
        &mut self,
        local_x: u16,
        local_y: u16,
        menu_width: u16,
        menu_height: u16,
    ) -> Option<ShapeType> {
        // Account for border (1 char) and title (1 line)
        if local_x < 1 || local_y < 2 {
            return None;
        }

        let inner_x = local_x - 1;
        let inner_y = local_y - 2;
        let inner_width = menu_width.saturating_sub(2);
        let inner_height = menu_height.saturating_sub(3);

        if inner_width == 0 || inner_height == 0 {
            return None;
        }

        // Calculate cell dimensions (3x2 grid)
        let cell_width = inner_width / 3;
        let cell_height = inner_height / 2;

        if cell_width == 0 || cell_height == 0 {
            return None;
        }

        // Determine which cell was clicked
        let col = (inner_x / cell_width).min(2) as usize;
        let row = (inner_y / cell_height).min(1) as usize;
        let index = row * 3 + col;

        if index < 6 {
            self.selected_index = index;
            ShapeType::from_grid_index(index)
        } else {
            None
        }
    }

    pub fn render(&self, frame: &mut Frame, area: Rect) {
        if !self.visible {
            return;
        }

        // Calculate centered popup area (60% width, 40% height for 3x2 grid)
        let popup_area = centered_rect(60, 40, area);

        // Clear the area behind the popup
        frame.render_widget(Clear, popup_area);

        // Create the block
        let block = Block::default()
            .title(" Select Shape (Arrows to navigate, Enter to place) ")
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::Green))
            .style(Style::default().bg(Color::Black));

        let inner = block.inner(popup_area);
        frame.render_widget(block, popup_area);

        // Calculate grid cell dimensions (3x2 grid)
        let cell_height = inner.height / 2;
        let cell_width = inner.width / 3;

        // Render each cell (6 shapes in 3x2 grid)
        for row in 0..2 {
            for col in 0..3 {
                let index = row * 3 + col;
                let shape_type = ShapeType::from_grid_index(index).unwrap_or(ShapeType::Circle);
                let is_selected = index == self.selected_index;

                // Calculate cell position
                let cell_x = inner.x + (col as u16 * cell_width);
                let cell_y = inner.y + (row as u16 * cell_height);
                let cell_area = Rect {
                    x: cell_x,
                    y: cell_y,
                    width: cell_width,
                    height: cell_height,
                };

                self.render_cell(frame, cell_area, shape_type, is_selected);
            }
        }
    }

    fn render_cell(&self, frame: &mut Frame, area: Rect, shape_type: ShapeType, is_selected: bool) {
        let style = if is_selected {
            Style::default()
                .fg(Color::LightGreen)
                .bg(Color::DarkGray)
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::Green)
        };

        // Build content: shape name centered
        let name = shape_type.name();
        let icon = shape_type.short_name();

        // Create a simple representation
        let mut lines = Vec::new();

        // Add padding at top
        if area.height > 3 {
            lines.push(Line::from(""));
        }

        // Center the icon and name
        let icon_line = format!("  {}  ", icon);
        lines.push(Line::from(Span::styled(
            center_text(&icon_line, area.width as usize),
            style,
        )));

        lines.push(Line::from(Span::styled(
            center_text(name, area.width as usize),
            style,
        )));

        // Add selection indicator
        if is_selected {
            lines.push(Line::from(Span::styled(
                center_text("[*]", area.width as usize),
                style,
            )));
        }

        let para = Paragraph::new(lines);
        frame.render_widget(para, area);
    }
}

/// Centers text within a given width.
fn center_text(text: &str, width: usize) -> String {
    let text_len = text.chars().count();
    if text_len >= width {
        text.to_string()
    } else {
        let padding = (width - text_len) / 2;
        format!("{}{}{}", " ".repeat(padding), text, " ".repeat(padding))
    }
}

/// Creates a centered rectangle within the given area.
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    let popup_width = (area.width * percent_x / 100).max(30);
    let popup_height = (area.height * percent_y / 100).max(12);

    let vertical = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length((area.height.saturating_sub(popup_height)) / 2),
            Constraint::Length(popup_height),
            Constraint::Min(0),
        ])
        .split(area);

    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Length((area.width.saturating_sub(popup_width)) / 2),
            Constraint::Length(popup_width),
            Constraint::Min(0),
        ])
        .split(vertical[1])[1]
}

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

    #[test]
    fn test_shape_menu_navigation() {
        let mut menu = ShapeMenu::new();
        assert_eq!(menu.selected_index(), 0);

        menu.select_right();
        assert_eq!(menu.selected_index(), 1);

        menu.select_down();
        assert_eq!(menu.selected_index(), 4);

        menu.select_left();
        assert_eq!(menu.selected_index(), 3);

        menu.select_up();
        assert_eq!(menu.selected_index(), 0);
    }

    #[test]
    fn test_shape_menu_bounds() {
        let mut menu = ShapeMenu::new();

        // Can't go left from column 0
        menu.select_left();
        assert_eq!(menu.selected_index(), 0);

        // Can't go up from row 0
        menu.select_up();
        assert_eq!(menu.selected_index(), 0);

        // Go to bottom-right corner (index 5 for 3x2 grid)
        menu.set_selected(5);

        // Can't go right from column 2
        menu.select_right();
        assert_eq!(menu.selected_index(), 5);

        // Can't go down from row 1 (bottom row of 3x2)
        menu.select_down();
        assert_eq!(menu.selected_index(), 5);
    }

    #[test]
    fn test_selected_shape_type() {
        let mut menu = ShapeMenu::new();

        assert_eq!(menu.selected_shape_type(), ShapeType::Circle);

        menu.set_selected(3); // Second row first col = Star
        assert_eq!(menu.selected_shape_type(), ShapeType::Star);

        menu.set_selected(5); // Bottom-right = LineVertical
        assert_eq!(menu.selected_shape_type(), ShapeType::LineVertical);
    }
}