post-push-party 0.1.11

Push code, earn points, throw a party!
use std::cell::Cell;

use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap};
use tui_scrollview::{ScrollView, ScrollViewState, ScrollbarVisibility};

use crate::party::PartyEntry;
use crate::state::State;
use crate::tui::widgets::{PaletteSelector, ShimmerBlock};

use super::{Action, View, ViewResult};

const ITEM_HEIGHT: u16 = 5;
const SCROLL_PADDING: u16 = ITEM_HEIGHT;
const PALETTE_SELECTOR_WIDTH: u16 = 22;

struct PartyItem<'a> {
    /// the party being detailed
    party: &'static PartyEntry,

    /// whether this party is enabled in user's state
    enabled: bool,

    /// whether this party is selected in the UI
    selected: bool,

    /// whether or not the user is currently selecting a palette for this item
    selecting_palette: bool,

    /// the index of the selected palette among the available palettes for this party
    palette_idx: usize,

    /// the palettes for this party
    palettes: Option<&'a Vec<String>>,

    /// used for animations
    tick: u32,
}

impl<'a> PartyItem<'a> {
    fn new(
        party: &'static PartyEntry,
        enabled: bool,
        selected: bool,
        selecting_palette: bool,
        palette_idx: usize,
        palettes: Option<&'a Vec<String>>,
        tick: u32,
    ) -> Self {
        Self {
            party,
            enabled,
            selected,
            selecting_palette,
            palette_idx,
            palettes,
            tick,
        }
    }
}

impl<'a> Widget for PartyItem<'a> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let inner = if self.selected {
            let block = ShimmerBlock::new(self.tick);
            let inner = block.inner(area).inner(Margin::new(1, 0));
            block.render(area, buf);
            inner
        } else {
            let block = Block::default()
                .borders(Borders::ALL)
                .padding(Padding::horizontal(1))
                .border_style(Style::default().gray());
            let inner = block.inner(area);
            block.render(area, buf);
            inner
        };

        // split horizontally: details on the left and palette selection on the right
        let [left, right] = inner.layout(&Layout::horizontal([
            Constraint::Fill(1),
            Constraint::Length(PALETTE_SELECTOR_WIDTH),
        ]));

        //
        // details
        //

        let [top, bottom] = left.layout(&Layout::vertical([
            Constraint::Length(1), // name & status
            Constraint::Length(2), // description
        ]));

        // name
        // enabled status
        let (status_symbol, status_text) = if self.enabled {
            ("".fg(Color::Green).dim(), "Enabled".dark_gray())
        } else {
            ("".fg(Color::Red).dim(), "Disabled".dark_gray())
        };
        let title_and_status = Line::from(vec![
            self.party.info.name.reset().bold(),
            " (".dark_gray().dim(),
            status_symbol,
            " ".into(),
            status_text,
            ")".dark_gray().dim(),
        ]);

        title_and_status.render(top, buf);

        // description
        let desc =
            Paragraph::new(self.party.info.description.reset().dim()).wrap(Wrap { trim: true });
        desc.render(bottom, buf);

        //
        // palette selection
        //
        let Some(palettes) = self.palettes else {
            return;
        };
        if !self.party.info.supports_color || palettes.len() == 1 {
            return;
        }

        let widget = PaletteSelector::new(palettes, self.palette_idx, self.selecting_palette);
        widget.render(right, buf);
    }
}

#[derive(Default, Clone, Copy)]
enum Mode {
    #[default]
    SelectingParty,
    SelectingPalette {
        /// index of selected palette within the (sorted!)
        /// list of palettes for the current party.
        palette_idx: usize,
    },
}

#[derive(Default)]
pub struct PartyView {
    /// index selected party
    selection: usize,

    /// determines what the user is doing
    mode: Mode,

    /// manages scrolling of entire view
    scroll_state: ScrollViewState,

    /// tracks viewport_height (determined at render time but used in `handle`)
    viewport_height: Cell<u16>,
}

impl PartyView {
    fn item_count(state: &State) -> usize {
        state.unlocked_parties().count()
    }

    fn selected_party(&self, state: &State) -> Option<&'static PartyEntry> {
        state.unlocked_parties().nth(self.selection)
    }

    fn update_scroll(&mut self) {
        let viewport_height = self.viewport_height.get();

        let selected_top = self.selection as u16 * ITEM_HEIGHT;
        let selected_bottom = selected_top + ITEM_HEIGHT;

        let current_offset = self.scroll_state.offset().y;
        let viewport_bottom = current_offset + viewport_height;

        if selected_bottom + SCROLL_PADDING > viewport_bottom {
            let new_offset = (selected_bottom + SCROLL_PADDING).saturating_sub(viewport_height);
            self.scroll_state.set_offset(Position::new(0, new_offset));
        } else if selected_top < current_offset + SCROLL_PADDING {
            let new_offset = selected_top.saturating_sub(SCROLL_PADDING);
            self.scroll_state.set_offset(Position::new(0, new_offset));
        }
    }

    fn palettes_for_selected_party<'b>(&'_ self, state: &'b State) -> Option<&'b Vec<String>> {
        self.selected_party(state)
            .map(|party| party.info.id)
            .and_then(|id| state.unlocked_palettes(id))
    }
}

impl View for PartyView {
    fn render(&self, frame: &mut Frame, area: Rect, state: &State, tick: u32) {
        self.viewport_height.set(area.height);

        let content_area = area.inner(Margin::new(1, 0));
        let content_width = content_area.width.saturating_sub(1); // leave room for scrollbar
        let content_height = Self::item_count(state) as u16 * ITEM_HEIGHT;

        let mut scroll_view = ScrollView::new(Size::new(content_width, content_height))
            .horizontal_scrollbar_visibility(ScrollbarVisibility::Never);

        for (i, party) in state.unlocked_parties().enumerate() {
            let enabled = state.is_party_enabled(party.info.id);
            let selected = self.selection == i;

            let (selecting_palette, palette_idx) = match self.mode {
                Mode::SelectingParty => (false, state.selected_palette_idx(party.info.id)),
                Mode::SelectingPalette { palette_idx } if selected => (true, palette_idx),
                Mode::SelectingPalette { .. } => (false, state.selected_palette_idx(party.info.id)),
            };

            let item = PartyItem::new(
                party,
                enabled,
                selected,
                selecting_palette,
                palette_idx,
                state.unlocked_palettes(party.info.id),
                tick,
            );
            let item_rect = Rect::new(0, i as u16 * ITEM_HEIGHT, content_width, ITEM_HEIGHT);
            scroll_view.render_widget(item, item_rect);
        }

        frame.render_stateful_widget(scroll_view, content_area, &mut self.scroll_state.clone());
    }

    fn handle(&mut self, action: Action, state: &mut State) -> ViewResult {
        let mode = self.mode;
        match (action, mode) {
            (Action::Up, Mode::SelectingParty) => {
                let count = Self::item_count(state);
                self.selection = (self.selection + count - 1) % count;
                self.update_scroll();
                ViewResult::Redraw
            }
            (Action::Up, Mode::SelectingPalette { palette_idx }) => {
                let count = self.palettes_for_selected_party(state).map(|v| v.len());

                if let Some(count) = count {
                    let palette_idx = (palette_idx + count) % (count + 1); // add one for random option
                    self.mode = Mode::SelectingPalette { palette_idx };
                }

                ViewResult::Redraw
            }
            (Action::Down, Mode::SelectingParty) => {
                self.selection = (self.selection + 1) % Self::item_count(state);
                self.update_scroll();
                ViewResult::Redraw
            }
            (Action::Down, Mode::SelectingPalette { palette_idx }) => {
                let count = self.palettes_for_selected_party(state).map(|v| v.len());

                if let Some(count) = count {
                    let palette_idx = (palette_idx + 1) % (count + 1); // add one for random option
                    self.mode = Mode::SelectingPalette { palette_idx };
                }
                ViewResult::Redraw
            }
            (Action::Select, Mode::SelectingParty) => {
                if let Some(party) = self.selected_party(state) {
                    state.toggle_party(party.info.id);
                }
                ViewResult::Redraw
            }
            (Action::Select, Mode::SelectingPalette { palette_idx }) => {
                if let Some(party) = self.selected_party(state) {
                    state.set_selected_palette(party.info.id, palette_idx);
                }
                self.mode = Mode::SelectingParty;
                ViewResult::Redraw
            }
            (Action::Back, Mode::SelectingPalette { .. }) => {
                self.mode = Mode::SelectingParty;
                ViewResult::Redraw
            }
            (Action::Palette, Mode::SelectingParty) => {
                if let Some(party) = self.selected_party(state)
                    && party.info.supports_color
                    && let Some(palettes) = state.unlocked_palettes(party.info.id)
                    && palettes.len() > 1
                {
                    let palette_idx = state.selected_palette_idx(party.info.id);
                    self.mode = Mode::SelectingPalette { palette_idx };
                }
                ViewResult::Redraw
            }
            (Action::Palette, Mode::SelectingPalette { .. }) => {
                self.mode = Mode::SelectingParty;
                ViewResult::Redraw
            }
            _ => ViewResult::None,
        }
    }

    fn key_hints(&self) -> Vec<(&'static str, &'static str)> {
        match self.mode {
            Mode::SelectingParty => vec![
                ("↑↓", "select"),
                ("enter", "toggle"),
                ("p", "change palette"),
                ("q", "quit"),
            ],
            Mode::SelectingPalette { .. } => vec![
                ("↑↓", "select"),
                ("enter", "set palette"),
                ("esc", "cancel"),
                ("q", "quit"),
            ],
        }
    }
}