cube-tui 0.1.8

Terminal UI timer and session manager for speedcubing, with optional web dashboard and BLE (GAN) timer support.
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Widget, Wrap};

use crate::model::{InspectionState, Model, TimerState};
use crate::utils::{format_elapsed, get_scramble_lines};
use crate::widgets::detailed_stats::DetailedStatsWidget;
use crate::widgets::details::DetailsWidget;
use crate::widgets::help::HelpWidget;
use crate::widgets::mean_detail::MeanDetailWidget;
use crate::widgets::scramble::ScrambleWidget;
use crate::widgets::stats::StatsWidget;

#[cfg(feature = "bluetooth")]
use crate::widgets::bluetooth::BluetoothWidget;

#[allow(clippy::too_many_lines)]
pub(crate) fn view(area: Rect, buf: &mut ratatui::buffer::Buffer, model: &mut Model) {
    let theme = *model.settings().theme();
    set_area_background(area, buf, theme.background());
    if model.show_help() {
        let help_widget = HelpWidget::new(model.help_scroll());
        model.set_help_max_scroll(HelpWidget::max_scroll_for_height(area.height));
        help_widget.render_with_theme(area, buf, &theme);
        return;
    }

    #[cfg(feature = "bluetooth")]
    if model.show_bluetooth() {
        use crate::model::bluetooth::BluetoothScreenState;

        let layout = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Fill(1), Constraint::Length(1)])
            .split(area);

        BluetoothWidget::new(
            model.bluetooth_devices().to_vec(),
            model.bluetooth_selected_index(),
            model.bluetooth_status().map(str::to_string),
            model.connected_device_id(),
        )
        .render_with_theme(layout[0], buf, &theme);

        let help_text = match model.bluetooth_screen_state() {
            BluetoothScreenState::Connected => Line::from(vec![
                Span::styled("↑/↓: select  ", Style::default().fg(theme.text())),
                Span::styled("Enter/x: disconnect  ", Style::default().fg(theme.text())),
                Span::styled("Esc: back to timer", Style::default().fg(theme.text())),
            ]),
            BluetoothScreenState::Connecting => Line::from(vec![
                Span::styled("↑/↓: select device  ", Style::default().fg(theme.text())),
                Span::styled("Esc: back to timer", Style::default().fg(theme.text())),
            ]),
            BluetoothScreenState::Searching => Line::from(vec![
                Span::styled("↑/↓: select device  ", Style::default().fg(theme.text())),
                Span::styled("Enter: connect  ", Style::default().fg(theme.text())),
                Span::styled("Esc: close", Style::default().fg(theme.text())),
            ]),
        };
        Paragraph::new(help_text)
            .alignment(Alignment::Center)
            .render(layout[1], buf);
        return;
    }

    if model.show_mean_detail() {
        let layout = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Fill(1), Constraint::Length(1)])
            .split(area);

        let widget = MeanDetailWidget::new(
            model.history(),
            model.detailed_stats_row(),
            model.detailed_stats_col(),
            model.mean_detail_selected_index(),
        );
        widget.render_with_theme(layout[0], buf, &theme);

        let help_text = Line::from(vec![
            Span::styled("↑/↓: select time  ", Style::default().fg(theme.text())),
            Span::styled("Enter: open details  ", Style::default().fg(theme.text())),
            Span::styled("Esc: back", Style::default().fg(theme.text())),
        ]);
        Paragraph::new(help_text)
            .alignment(Alignment::Center)
            .render(layout[1], buf);
        return;
    }

    if model.show_detailed_stats() {
        let layout = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Fill(1), Constraint::Length(1)])
            .split(area);

        DetailedStatsWidget::new(
            model.history().clone(),
            model.detailed_stats_row(),
            model.detailed_stats_col(),
        )
        .render_with_theme(layout[0], buf, &theme);

        let help_text = Line::from(vec![
            Span::styled("↑/↓: navigate  ", Style::default().fg(theme.text())),
            Span::styled("←/→: mo3/ao5  ", Style::default().fg(theme.text())),
            Span::styled("Enter: view mean  ", Style::default().fg(theme.text())),
            Span::styled("Esc: back", Style::default().fg(theme.text())),
        ]);
        Paragraph::new(help_text)
            .alignment(Alignment::Center)
            .render(layout[1], buf);
        return;
    }

    if model.show_details() {
        let details_layout = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Fill(1), Constraint::Length(1)])
            .split(area);

        DetailsWidget::new(
            model.history().selected_time(),
            model.selected_details_modifier_index(),
        )
        .render_with_theme(details_layout[0], buf, &theme);

        let details_help = Line::from(vec![
            Span::styled(
                "Space: toggle modifier  ",
                Style::default().fg(theme.text()),
            ),
            Span::styled("↑/↓: select modifier  ", Style::default().fg(theme.text())),
            Span::styled("←/→: navigate times  ", Style::default().fg(theme.text())),
            Span::styled("d: delete  ", Style::default().fg(theme.text())),
            Span::styled("Esc: close", Style::default().fg(theme.text())),
        ]);
        Paragraph::new(details_help)
            .alignment(Alignment::Center)
            .render(details_layout[1], buf);
        return;
    }

    if model.zen_enabled() && matches!(model.timer_state(), TimerState::Running(_)) {
        let vertical = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Fill(1),
                Constraint::Length(1),
                Constraint::Fill(1),
            ])
            .split(area);
        Paragraph::new(Line::from(Span::styled(
            "Solving...",
            Style::default()
                .fg(Color::Green)
                .add_modifier(Modifier::BOLD)
                .bg(theme.background()),
        )))
        .alignment(Alignment::Center)
        .render(vertical[1], buf);
        return;
    }

    let settings = model.settings();
    let show_scramble = settings.scramble();
    let show_history = settings.history();
    let show_stats = settings.stats();

    let outer_constraints = if show_scramble {
        let scramble_lines = get_scramble_lines(model.scramble(), area.width);
        let scramble_height = (scramble_lines + 2).min(area.height.saturating_sub(1));
        vec![
            Constraint::Length(scramble_height),
            Constraint::Fill(1),
            Constraint::Length(1),
        ]
    } else {
        vec![Constraint::Fill(1), Constraint::Length(1)]
    };
    let outer_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints(outer_constraints)
        .margin(1)
        .split(area);

    let main_area_index = if show_scramble { 1 } else { 0 };
    let help_area_index = if show_scramble { 2 } else { 1 };

    let mut main_constraints = Vec::new();
    let mut history_area_index = None;
    let mut stats_area_index = None;

    if show_history {
        history_area_index = Some(main_constraints.len());
        main_constraints.push(Constraint::Length(24));
    }

    let timer_area_index = main_constraints.len();
    main_constraints.push(Constraint::Min(10));

    if show_stats {
        stats_area_index = Some(main_constraints.len());
        main_constraints.push(Constraint::Length(30));
    }

    let main_layout = Layout::default()
        .direction(Direction::Horizontal)
        .constraints(main_constraints)
        .split(outer_layout[main_area_index]);

    if show_scramble {
        ScrambleWidget::new(model.scramble(), model.event().name()).render_with_theme(
            outer_layout[0],
            buf,
            &theme,
        );
    }

    let history_title = format!(
        "Session: {:02}/{:02}{}",
        model.current_session_index() + 1,
        model.session_count(),
        if model.is_at_max_sessions() {
            " (max 99)"
        } else {
            ""
        }
    );
    if let Some(index) = history_area_index {
        let history_block = Block::default()
            .title(history_title)
            .borders(Borders::ALL)
            .border_style(Style::default().fg(theme.border()));
        history_block.render(main_layout[index], buf);
        let history_area = inner_area(main_layout[index]);
        if model.main_focus_is_stats() {
            model
                .history()
                .clone()
                .without_selection_highlight()
                .render_with_theme(history_area, buf, &theme);
        } else {
            model
                .history()
                .clone()
                .render_with_theme(history_area, buf, &theme);
        }
    }

    #[cfg(feature = "bluetooth")]
    let bt_label = model
        .connected_device_name()
        .map_or_else(String::new, |name| format!(" | 🔗 {name}"));
    #[cfg(not(feature = "bluetooth"))]
    let bt_label = String::new();
    let timer_title = format!(
        "Timer{}{}{bt_label}",
        if model.inspection_enabled() {
            " | Inspection: On"
        } else {
            ""
        },
        if model.zen_enabled() {
            " | Zen: On"
        } else {
            ""
        }
    );
    let timer_block = Block::default()
        .title(timer_title)
        .borders(Borders::ALL)
        .border_style(Style::default().fg(theme.border()));
    let (timer_text, timer_style) = timer_display(model);
    Paragraph::new(Line::from(Span::styled(timer_text, timer_style)))
        .block(timer_block)
        .alignment(Alignment::Center)
        .wrap(Wrap { trim: true })
        .render(main_layout[timer_area_index], buf);

    if let Some(index) = stats_area_index {
        let stats_widget = if model.main_focus_is_stats() {
            StatsWidget::new(model.history().clone())
                .with_selection(model.main_stats_row(), model.main_stats_col())
        } else {
            StatsWidget::new(model.history().clone())
        };
        stats_widget.render_with_theme(main_layout[index], buf, &theme);
    }

    let mut help_spans = vec![
        Span::styled("Space: hold/release  ", Style::default().fg(theme.text())),
        Span::styled("Enter: details  ", Style::default().fg(theme.text())),
        Span::styled("r: reset  ", Style::default().fg(theme.text())),
        Span::styled("q: quit  ", Style::default().fg(theme.text())),
        Span::styled("?: help", Style::default().fg(theme.text())),
    ];
    if show_history && show_stats {
        help_spans.insert(
            2,
            Span::styled("Tab: history/stats  ", Style::default().fg(theme.text())),
        );
    }
    Paragraph::new(Line::from(help_spans))
        .alignment(Alignment::Center)
        .render(outer_layout[help_area_index], buf);
}

fn set_area_background(area: Rect, buf: &mut ratatui::buffer::Buffer, color: Color) {
    let style = Style::default().bg(color);
    let spaces = " ".repeat(area.width as usize);
    for y in area.top()..area.bottom() {
        buf.set_string(area.x, y, &spaces, style);
    }
}

const fn inner_area(area: Rect) -> Rect {
    Rect::new(
        area.x + 1,
        area.y + 1,
        area.width.saturating_sub(2),
        area.height.saturating_sub(2),
    )
}

fn timer_display(model: &Model) -> (String, Style) {
    let theme = model.settings().theme();
    let style = match model.timer_state() {
        TimerState::Idle => Style::default().fg(theme.text()),
        TimerState::Pulsed | TimerState::Inspection(InspectionState::Pulsed(_)) => {
            Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
        }
        TimerState::Running(_) => Style::default()
            .fg(Color::Green)
            .add_modifier(Modifier::BOLD),
        TimerState::Inspection(InspectionState::Running(_)) => Style::default()
            .fg(Color::Yellow)
            .add_modifier(Modifier::BOLD),
    };

    let text = match model.timer_state() {
        TimerState::Pulsed => format_elapsed(0),
        TimerState::Inspection(_) => {
            let elapsed_ms = model.elapsed_ms();
            let remaining_ms = 15_000_u64.saturating_sub(elapsed_ms);
            format!("Inspect: {}", format_elapsed(remaining_ms))
        }
        _ => format_elapsed(model.elapsed_ms()),
    };

    (text, style)
}