mlbt 0.4.0

A terminal user interface for the MLB stats API. Watch a baseball game in your terminal! ⚾
use tui::backend::Backend;
use tui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use tui::style::{Color, Modifier, Style};
use tui::text::Line;
use tui::widgets::{Block, BorderType, Borders, Clear, Paragraph, Tabs, Widget};
use tui::{Frame, Terminal};

use crate::app::{App, DebugState, MenuItem};
use crate::components::debug::DebugInfo;
use crate::state::network::{ERROR_CHAR, LoadingState};
use crate::ui::boxscore::TeamBatterBoxscoreWidget;
use crate::ui::date_selector::DateSelectorWidget;
use crate::ui::decision_pitchers::DecisionPitchersWidget;
use crate::ui::gameday::gameday_widget::GamedayWidget;
use crate::ui::gameday::win_probability::WinProbabilityWidget;
use crate::ui::help::help_widget::HelpWidget;
use crate::ui::input_popup::{InputPopup, popup_cursor_position};
use crate::ui::layout::LayoutAreas;
use crate::ui::linescore::LineScoreWidget;
use crate::ui::logs::LogWidget;
use crate::ui::player_profile::PlayerProfileWidget;
use crate::ui::probable_pitchers::ProbablePitchersWidget;
use crate::ui::schedule::ScheduleWidget;
use crate::ui::standings::StandingsWidget;
use crate::ui::stats::{STATS_OPTIONS_WIDTH, StatsDataWidget, StatsOptionsWidget};
use crate::ui::styling::{TEXT_COLOR, border_style};
use crate::ui::team_page::TeamPageWidget;

static TABS: &[&str; 4] = &["Scoreboard", "Gameday", "Stats", "Standings"];

pub fn draw<B>(terminal: &mut Terminal<B>, app: &mut App, is_loading: LoadingState)
where
    B: Backend,
{
    let current_size = terminal.size().unwrap_or_default();
    let mut main_layout = LayoutAreas::new(current_size);

    if current_size.width <= 10 || current_size.height <= 10 {
        return;
    }

    terminal
        .draw(|f| {
            main_layout.update(f.area(), app.settings.full_screen);

            if !app.settings.full_screen {
                draw_tabs(f, &main_layout.top_bar, app);
            }

            match app.state.active_tab {
                MenuItem::Scoreboard => draw_scoreboard(f, main_layout.main, app),
                MenuItem::DatePicker => {
                    match app.state.previous_tab {
                        MenuItem::Scoreboard => draw_scoreboard(f, main_layout.main, app),
                        MenuItem::Standings => draw_standings(f, main_layout.main, app),
                        MenuItem::Stats => draw_stats(f, main_layout.main, app),
                        _ => (),
                    }
                    draw_date_picker(f, main_layout.main, app);
                }
                MenuItem::Gameday => draw_gameday(f, main_layout.main, app),
                MenuItem::Stats => draw_stats(f, main_layout.main, app),
                MenuItem::Standings => draw_standings(f, main_layout.main, app),
                MenuItem::Help => draw_help(f, f.area(), app),
            }
            if app.state.debug_state == DebugState::On {
                let mut dbi = DebugInfo::new();
                dbi.gather_info(f, app);
                dbi.render(f, main_layout.main, app.state.show_logs);
            }

            draw_loading_spinner(f, f.area(), app, is_loading);
        })
        .unwrap();
}

pub fn default_border<'a>() -> Block<'a> {
    Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .border_style(border_style())
}

fn draw_border(f: &mut Frame, rect: Rect) {
    let block = default_border();
    f.render_widget(block, rect);
}

fn draw_loading_spinner(f: &mut Frame, area: Rect, app: &App, loading: LoadingState) {
    if !loading.is_loading && loading.spinner_char != ERROR_CHAR {
        return;
    }

    let style = match loading.spinner_char {
        ERROR_CHAR => Style::default().fg(Color::Red),
        _ => Style::default().fg(Color::default()),
    };

    let spinner = Paragraph::new(loading.spinner_char.to_string())
        .alignment(Alignment::Right)
        .style(style);

    let area = if app.settings.full_screen || app.state.active_tab == MenuItem::Help {
        // render in the bottom right
        Rect::new(
            area.width.saturating_sub(3),
            area.height.saturating_sub(2),
            1,
            1,
        )
    } else {
        // render in the top right
        Rect::new(area.width.saturating_sub(11), 1, 1, 1)
    };
    f.render_widget(spinner, area);
}

fn draw_tabs(f: &mut Frame, top_bar: &[Rect], app: &App) {
    let border_style = border_style();
    let border_type = BorderType::Rounded;

    let titles: Vec<Line> = TABS.iter().map(|t| Line::from(*t)).collect();

    let tabs = Tabs::new(titles)
        .block(
            Block::default()
                .borders(Borders::LEFT | Borders::BOTTOM | Borders::TOP)
                .border_type(border_type)
                .border_style(border_style),
        )
        .highlight_style(
            // underline the active tab
            Style::default().add_modifier(Modifier::UNDERLINED),
        )
        .select(app.state.active_tab as usize)
        .style(TEXT_COLOR);
    f.render_widget(tabs, top_bar[0]);

    let help = Paragraph::new("Help: ? ")
        .alignment(Alignment::Right)
        .block(
            Block::default()
                .borders(Borders::RIGHT | Borders::BOTTOM | Borders::TOP)
                .border_type(border_type)
                .border_style(border_style),
        )
        .style(TEXT_COLOR);
    f.render_widget(help, top_bar[1]);
}

fn draw_scoreboard(f: &mut Frame, rect: Rect, app: &mut App) {
    // TODO calculate width based on table sizes
    let direction = match f.area().width {
        w if w < 125 => Direction::Vertical,
        _ => Direction::Horizontal,
    };
    let [scoreboard, boxscore] = Layout::default()
        .direction(direction)
        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
        .areas(rect);

    // display scores on left side
    f.render_stateful_widget(
        ScheduleWidget {
            tz_abbreviation: app.settings.timezone_abbreviation.clone(),
        },
        scoreboard,
        &mut app.state.schedule,
    );
    if app.state.schedule.show_win_probability {
        draw_win_probability(f, scoreboard, app);
    }

    // display probable pitchers or line score and box score on right
    if let Some(matchup) = app.state.schedule.get_probable_pitchers_opt() {
        f.render_widget(ProbablePitchersWidget { matchup }, boxscore);
    } else {
        draw_border(f, boxscore);
        draw_linescore_boxscore(f, boxscore, app);
    }
}

fn draw_linescore_boxscore(f: &mut Frame, rect: Rect, app: &mut App) {
    // `is_final` only returns true once the live api has populated GameState, which keeps
    // decisions in sync with the linescore and box score instead of leading them on scroll.
    let decision_pitchers = if app.state.gameday.is_final() {
        app.state
            .schedule
            .get_decision_pitchers_for_game(app.state.gameday.current_game_id())
    } else {
        None
    };
    let pitcher_count = decision_pitchers.map_or(0, |d| d.count());
    let chunks = LayoutAreas::for_boxscore(rect, pitcher_count);

    f.render_widget(
        LineScoreWidget {
            active: app.state.box_score.active_team,
            linescore: &app.state.gameday.game.linescore,
        },
        chunks[0],
    );
    let boxscore_chunk = if let Some(decisions) = decision_pitchers {
        f.render_widget(DecisionPitchersWidget { decisions }, chunks[1]);
        chunks[2]
    } else {
        chunks[1]
    };
    f.render_widget(
        TeamBatterBoxscoreWidget {
            active: app.state.box_score.active_team,
            state: &mut app.state.box_score,
        },
        boxscore_chunk,
    );
}

fn draw_date_picker(f: &mut Frame, rect: Rect, app: &mut App) {
    let chunk = LayoutAreas::create_date_picker(rect);
    f.render_stateful_widget(DateSelectorWidget {}, chunk, &mut app.state.date_input);

    let (cx, cy) = popup_cursor_position(chunk, app.state.date_input.text.len() as u16);
    f.set_cursor_position((cx, cy));
}

fn draw_gameday(f: &mut Frame, rect: Rect, app: &mut App) {
    f.render_widget(
        GamedayWidget {
            active: app.state.box_score.active_team,
            state: &app.state.gameday,
            boxscore_state: &mut app.state.box_score,
        },
        rect,
    );
}

fn draw_stats(f: &mut Frame, rect: Rect, app: &mut App) {
    if let Some(tp) = &mut app.state.stats.team_page {
        if let Some(profile) = &mut tp.player_profile {
            PlayerProfileWidget { state: profile }.render(rect, f.buffer_mut());
            return;
        }
        TeamPageWidget { state: tp }.render(rect, f.buffer_mut());
        return;
    }
    if let Some(profile) = &mut app.state.stats.player_profile {
        PlayerProfileWidget { state: profile }.render(rect, f.buffer_mut());
        return;
    }

    // Split horizontally first: data pane (left) and options pane (right)
    let (data_area, options_area) =
        if app.state.stats.show_options && rect.width > STATS_OPTIONS_WIDTH {
            let [data, options] = Layout::horizontal([
                Constraint::Length(rect.width - STATS_OPTIONS_WIDTH),
                Constraint::Length(STATS_OPTIONS_WIDTH),
            ])
            .areas(rect);
            (data, Some(options))
        } else {
            (rect, None)
        };

    // If search is open, shrink only the data pane to make room for the search bar
    let (data_table_area, search_area) = if app.state.stats.search.is_open {
        let [table, search] =
            Layout::vertical([Constraint::Fill(1), Constraint::Length(4)]).areas(data_area);
        (table, Some(search))
    } else {
        (data_area, None)
    };

    // TODO by taking into account the width of the options pane I'm basically removing that amount
    // of space for columns. If I didn't, you could select columns that would be covered by the
    // options pane, but then when its disabled would become visible.
    app.state.stats.table.trim_columns(data_table_area.width);
    f.render_stateful_widget(StatsDataWidget {}, data_table_area, &mut app.state.stats);

    if let Some(options_area) = options_area {
        f.render_stateful_widget(StatsOptionsWidget {}, options_area, &mut app.state.stats);
    }

    if let Some(search_area) = search_area {
        let title = format!("Search {}", app.state.stats.stat_type.search_label());
        let total = app.state.stats.total_row_count();
        let filtered = app.state.stats.search.matched_indices.len();
        let info = if app.state.stats.search.is_filtering() {
            format!("{}/{}", filtered, total)
        } else {
            format!("{}", total)
        };
        InputPopup {
            title: &title,
            instructions: "Press Enter to search or Esc to cancel",
            input_text: &app.state.stats.search.input,
            border_color: Color::Blue,
            info: Some(&info),
        }
        .render(search_area, f.buffer_mut());

        let (cx, cy) =
            popup_cursor_position(search_area, app.state.stats.search.input.len() as u16);
        f.set_cursor_position((cx, cy));
    }
}

fn draw_standings(f: &mut Frame, rect: Rect, app: &mut App) {
    if let Some(tp) = &mut app.state.standings.team_page {
        if let Some(profile) = &mut tp.player_profile {
            PlayerProfileWidget { state: profile }.render(rect, f.buffer_mut());
            return;
        }
        TeamPageWidget { state: tp }.render(rect, f.buffer_mut());
        return;
    }
    f.render_stateful_widget(StandingsWidget {}, rect, &mut app.state.standings);
}

fn draw_win_probability(f: &mut Frame, rect: Rect, app: &mut App) {
    // only render if it doesn't overlap the schedule
    let minimum_size =
        WinProbabilityWidget::get_min_table_height() + app.state.schedule.schedule.len() + 2; // +2 for borders 
    if rect.height > minimum_size as u16 {
        f.render_widget(
            WinProbabilityWidget {
                game: &app.state.gameday.game,
                selected_at_bat: app.state.gameday.selected_at_bat(),
                active_tab: MenuItem::Scoreboard,
            },
            rect,
        );
    }
}

fn draw_help(f: &mut Frame, rect: Rect, app: &mut App) {
    f.render_widget(Clear, rect);

    if app.state.show_logs {
        draw_border(f, rect);
        f.render_widget(LogWidget {}, rect);
        return;
    }

    draw_border(f, rect);
    f.render_stateful_widget(
        HelpWidget {
            // use previous tab because help has been set to active at this point
            active_tab: app.state.previous_tab,
            settings: &app.settings,
            editor: &app.state.settings_editor,
        },
        rect,
        &mut app.state.help.state,
    );
}