bawa 0.1.0

A TUI game save organizer
Documentation
use super::{confirmation::draw_confirmation_window, popup::window_from_dimensions, set_cursor};
use crate::{
    app::{App, StatefulList},
    config::THEME,
    fuzzy_finder::FuzzyFinder,
    game::creation::{CreatingGame, Step},
    help::Help,
    input::Mode,
    message::Kind as MessageKind,
    tree::widget::Tree,
};
use ratatui::{
    layout::{Alignment, Constraint, Layout, Margin, Rect},
    style::Style,
    text::{Line, Span},
    widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap},
    Frame,
};
use std::fmt::Display;

pub fn draw(f: &mut Frame, app: &mut App) {
    let main_layout = if app.footer_input.is_some() || !app.message.is_empty() {
        let [main, footer] =
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(f.area());
        draw_footer(f, app, footer);
        main
    } else {
        f.area()
    };

    draw_main(f, app, main_layout);

    if app.mode.is_profile_selection() {
        draw_list_with_help(
            f,
            format!("Profiles [{}]", app.games.get_game_unchecked().name()),
            app.games.get_profiles_mut(),
            &app.help.bindings.profile_selection,
        );
    } else if app.mode.is_game_selection() {
        draw_list_with_help(
            f,
            "Games".to_owned(),
            &mut app.games.inner,
            &app.help.bindings.game_selection,
        );

        if app.mode.is_game_creation() {
            draw_game_creation(f, &mut app.game_creation);
        }
    }

    if app.fuzzy_finder.is_active() {
        draw_fuzzy_finder(
            f,
            &mut app.fuzzy_finder,
            window_from_dimensions(50, 90, f.area()),
        );
    }

    if app.help.visible {
        draw_help(f, &mut app.help);
    }

    if let Mode::Confirmation(ref mut prompt) = app.mode {
        draw_confirmation_window(f, prompt);
    }
}

fn draw_game_creation(f: &mut Frame<'_>, state: &mut CreatingGame) {
    match &mut state.step {
        Step::EnterName | Step::EnterPath => (),
        Step::PresetOrManual(use_preset) => {
            let area = window_from_dimensions(10, 50, f.area());
            f.render_widget(Clear, area);

            let block = Block::bordered()
                .title(Line::styled(
                    "Path Selection Method",
                    THEME.confirmation_border,
                ))
                .border_type(BorderType::Rounded)
                .border_style(THEME.confirmation_border)
                .title_alignment(Alignment::Center);

            f.render_widget(&block, area);
            let area = window_from_dimensions(1, area.width, block.inner(area));

            let [manual_area, preset_area] =
                Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area);

            let manual = Paragraph::new(Line::styled(
                "Manual",
                if *use_preset {
                    Style::default()
                } else {
                    THEME.confirmation_border
                },
            ))
            .centered();
            let preset = Paragraph::new(Line::styled(
                "Preset",
                if *use_preset {
                    THEME.confirmation_border
                } else {
                    Style::default()
                },
            ))
            .centered();

            f.render_widget(manual, manual_area);
            f.render_widget(preset, preset_area);
        }
        Step::Presets(presets) => draw_list_with_help(f, "Presets".to_owned(), presets, &[]),
        Step::SaveFileLocations(paths) => draw_list_with_help(f, "Paths".to_owned(), paths, &[]),
    }
}

fn draw_main(f: &mut Frame, app: &mut App, area: Rect) {
    let Some(game) = app.games.get_game() else {
        return;
    };

    let Some(profile) = game.get_profile() else {
        return;
    };

    f.render_stateful_widget(
        Tree::from(&profile.entries)
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .border_type(BorderType::Rounded)
                    .title(format!("{} [{}]", profile.name(), game.name()))
                    .title_style(THEME.title),
            )
            .highlight_style(THEME.selected)
            .marked_style(THEME.marked)
            .active_style(THEME.active),
        area,
        &mut app.tree_state,
    );
}

pub fn draw_fuzzy_finder(f: &mut Frame, fuzzy_finder: &mut FuzzyFinder, area: Rect) {
    f.render_widget(Clear, area);

    let [mut search_bar_area, results_area] =
        Layout::vertical([Constraint::Length(3), Constraint::Fill(1)]).areas(area);

    let search_block = Block::default()
        .title(Span::styled("Search", THEME.title))
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded);

    f.render_widget(&search_block, search_bar_area);
    search_bar_area = search_block.inner(search_bar_area);

    let [prompt_area, input_area, mut counter_area] = Layout::horizontal([
        Constraint::Length(fuzzy_finder.input.cursor_offset),
        Constraint::Length(fuzzy_finder.input.visible_width() as u16),
        Constraint::Fill(1),
    ])
    .areas(search_bar_area);

    let prompt = Paragraph::new(fuzzy_finder.input.prompt.clone()).style(THEME.fuzzy_prompt);
    f.render_widget(prompt, prompt_area);

    fuzzy_finder.input.update_width(search_bar_area.width);

    let input = Paragraph::new(fuzzy_finder.input.to_string());
    set_cursor(f, &fuzzy_finder.input, prompt_area);
    f.render_widget(input, input_area);

    counter_area = counter_area.inner(Margin::new(1, 0));
    let counter = format!(
        "{} / {}",
        fuzzy_finder.match_count, fuzzy_finder.total_count
    );
    if counter.len() <= counter_area.width.into() {
        let counter = Paragraph::new(counter)
            .style(THEME.fuzzy_counter)
            .right_aligned();
        f.render_widget(counter, counter_area);
    }

    let selected_idx = fuzzy_finder.matched.state.selected().unwrap_or(0);
    let results = List::new(
        fuzzy_finder
            .matched
            .items
            .iter()
            .enumerate()
            .map(|(idx, item)| {
                let mut line = item.highlight_slices();
                line.insert(0, (if selected_idx == idx { "> " } else { "  " }, false));
                let line = Line::from(
                    line.into_iter()
                        .map(|(slice, highlighted)| {
                            if highlighted {
                                Span::styled(slice, THEME.highlight)
                            } else if selected_idx == idx {
                                Span::styled(slice, THEME.fuzzy_selected)
                            } else {
                                Span::raw(slice)
                            }
                        })
                        .collect::<Vec<Span>>(),
                );
                ListItem::new(line)
            }),
    )
    .block(
        Block::default()
            .title(Span::styled("Results", THEME.title))
            .borders(Borders::ALL)
            .border_type(BorderType::Rounded),
    );

    f.render_stateful_widget(results, results_area, &mut fuzzy_finder.matched.state);
}

fn draw_help(f: &mut Frame, help: &mut Help) {
    let window = window_from_dimensions(45, 80, f.area());
    f.render_widget(Clear, window);

    let block = Block::default()
        .borders(Borders::ALL)
        .title(Span::styled("Help", THEME.title));

    let help_entries = help
        .bindings
        .iter()
        .map(|(key, desc)| Line::from(vec![Span::styled(key, THEME.help), Span::raw(*desc)]))
        .collect::<Vec<Line>>();

    let offset = help.scroller.offset(block.inner(window), &help_entries);

    let mut help_text = Paragraph::new(help_entries)
        .scroll((offset, 0))
        .block(block);

    if window.width > 0 {
        help_text = help_text.wrap(Wrap { trim: false });
    }

    f.render_widget(help_text, window);
}

fn draw_list_with_help<T: Display>(
    f: &mut Frame,
    title: String,
    list: &mut StatefulList<T>,
    help_entries: &[(String, &str)],
) {
    const VER_MARGIN: u16 = 6;
    const RIGHT_PADDING: u16 = 4;

    let item_texts: Vec<Span> = list
        .items
        .iter()
        .map(ToString::to_string)
        .map(Span::raw)
        .collect();

    let mut spans = Vec::new();

    for entry in help_entries {
        spans.push(Span::styled(entry.0.clone(), THEME.help));
        spans.push(Span::raw(entry.1));
    }

    let help_text = Line::from(spans);

    let help_text_width = help_text.width();
    let help_text_height = (help_text_width as u16).div_ceil(f.area().width);

    let max_width = item_texts
        .iter()
        .map(Span::width)
        .max()
        .unwrap_or(0)
        .max(help_text_width) as u16
        + RIGHT_PADDING;

    let frame_height = f.area().height;

    let mut max_height = item_texts.len() as u16 + help_text_height + 2;
    max_height = if frame_height <= max_height + VER_MARGIN {
        frame_height.saturating_sub(VER_MARGIN)
    } else {
        max_height
    }
    .max(20);

    let window = window_from_dimensions(max_height, max_width, f.area());
    f.render_widget(Clear, window);

    f.render_widget(
        Block::default()
            .borders(Borders::ALL)
            .title(title)
            .title_style(THEME.title),
        window,
    );

    let [entry_area, help_area] =
        Layout::vertical([Constraint::Min(1), Constraint::Length(help_text_height)])
            .margin(1)
            .areas(window);

    let mut help_widget = Paragraph::new(help_text);
    if window.width > 0 {
        help_widget = help_widget.wrap(Wrap { trim: false });
    }

    let list_items = item_texts
        .into_iter()
        .map(ListItem::new)
        .collect::<Vec<ListItem>>();

    let w = List::new(list_items).highlight_style(THEME.selected);

    f.render_stateful_widget(w, entry_area, &mut list.state);
    f.render_widget(help_widget, help_area);
}

fn draw_footer(f: &mut Frame, app: &mut App, area: Rect) {
    let line = if let Some(input) = &mut app.footer_input {
        set_cursor(f, input, area);
        input.update_width(area.width);
        let prompt = Span::raw(&input.prompt);
        let text = if matches!(app.mode, Mode::Search(_)) && app.search.no_match() {
            Span::styled(input.to_string(), THEME.error)
        } else {
            Span::raw(input.to_string())
        };
        Line::from(vec![prompt, text])
    } else if !app.message.is_empty() {
        Line::from(Span::styled(
            app.message.to_owned(),
            match app.message.kind {
                MessageKind::Info => Style::default(),
                MessageKind::Error => THEME.error,
                MessageKind::Warning => THEME.warning,
            },
        ))
    } else {
        unreachable!()
    };

    f.render_widget(line, area);
}