use std::cell::Cell;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use tui_scrollview::{ScrollView, ScrollViewState, ScrollbarVisibility};
use crate::bonus_track::{ALL_TRACKS, Reward, Tier};
use crate::state::State;
use crate::tui::action::{Action, Route, StoreRoute};
use crate::tui::views::{MessageType, View, ViewResult};
use crate::tui::widgets::ShimmerBlock;
const ITEM_HEIGHT: u16 = 5;
const SCROLL_PADDING: u16 = ITEM_HEIGHT;
const COST_INFO_WIDTH: u16 = 30;
struct BonusItem<'a> {
name: &'static str,
description: &'static str,
tiers: &'static [Tier],
state: &'a State,
selected: bool,
tick: u32,
owned_level: usize,
}
impl<'a> BonusItem<'a> {
fn new(
name: &'static str,
description: &'static str,
tiers: &'static [Tier],
state: &'a State,
selected: bool,
tick: u32,
owned_level: usize,
) -> Self {
Self {
name,
description,
tiers,
state,
selected,
tick,
owned_level,
}
}
}
impl<'a> Widget for BonusItem<'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);
block.render(area, buf);
inner
} else {
let block = Block::default()
.border_style(Style::default().dim())
.borders(Borders::ALL);
let inner = block.inner(area);
block.render(area, buf);
inner
};
let split = Layout::horizontal([
Constraint::Fill(1), Constraint::Length(COST_INFO_WIDTH), ])
.split(inner);
let name_split = Layout::vertical([
Constraint::Length(1), Constraint::Fill(1), ])
.split(split[0].inner(Margin::new(1, 0)));
let current_reward = if self.owned_level == 0 {
None
} else {
self.tiers.get(self.owned_level - 1).map(|t| t.reward)
};
let mut name_spans = vec![self.name.reset().bold()];
if let Some(reward) = current_reward {
name_spans.extend([
" (currently ".dark_gray(),
format_reward(reward).magenta(),
")".dark_gray(),
]);
}
Line::from(name_spans).render(name_split[0], buf);
let description = Paragraph::new(self.description)
.wrap(Wrap::default())
.reset()
.dim();
description.render(name_split[1], buf);
let block = Block::default()
.borders(Borders::LEFT)
.border_style(Style::default().dark_gray());
let inner = block.inner(split[1]);
block.render(split[1], buf);
let split = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
let middle = split[1];
let next_tier = self.tiers.get(self.owned_level);
let cost = next_tier.map(|t| t.cost).unwrap_or(0);
let next_reward = next_tier
.map(|t| t.reward)
.unwrap_or_else(|| Reward::FlatPoints(0));
let affordable = self.state.party_points >= cost;
let cost_style = if affordable { Color::Green } else { Color::Red };
if self.owned_level == 0 {
Line::from(vec![
"Buy ".reset(),
format_reward(next_reward).magenta(),
" for ".reset().dim(),
Span::from(format_cost(cost)).style(cost_style),
])
.alignment(Alignment::Center)
.render(middle, buf);
}
else if self.owned_level < self.tiers.len() {
Line::from(vec![
"Upgrade to ".reset(),
format_reward(next_reward).magenta(),
" for ".reset().dim(),
Span::from(format_cost(cost)).style(cost_style),
])
.alignment(Alignment::Center)
.render(middle, buf);
}
else {
Line::from("Already at max level!")
.cyan()
.dim()
.alignment(Alignment::Center)
.render(middle, buf);
}
}
}
#[derive(Default)]
pub struct BonusesView {
selection: usize,
scroll_state: ScrollViewState,
viewport_height: Cell<u16>,
}
impl BonusesView {
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));
}
}
}
impl View for BonusesView {
fn render(&self, frame: &mut Frame, area: Rect, state: &State, tick: u32) {
self.viewport_height.set(area.height);
let split = Layout::vertical([Constraint::Length(2), Constraint::Fill(1)]).split(area);
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().dark_gray());
let header = Paragraph::new("Bonuses")
.alignment(Alignment::Center)
.style(Style::default().fg(Color::Reset))
.block(block);
frame.render_widget(header, split[0]);
let content_area = split[1].inner(Margin::new(1, 0));
let content_width = content_area.width.saturating_sub(1); let content_height = ALL_TRACKS.len() as u16 * ITEM_HEIGHT;
let mut scroll_view = ScrollView::new(Size::new(content_width, content_height))
.horizontal_scrollbar_visibility(ScrollbarVisibility::Never);
for (i, track) in ALL_TRACKS.iter().enumerate() {
let owned_level = state.bonus_level(track.id()) as usize;
let item = BonusItem::new(
track.name(),
track.description(),
track.tiers(),
state,
self.selection == i,
tick,
owned_level,
);
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 {
match action {
Action::Up => {
let count = ALL_TRACKS.len();
self.selection = (self.selection + count - 1) % count;
self.update_scroll();
ViewResult::Redraw
}
Action::Down => {
self.selection = (self.selection + 1) % ALL_TRACKS.len();
self.update_scroll();
ViewResult::Redraw
}
Action::Select => {
let track = ALL_TRACKS[self.selection];
let current_level = state.bonus_level(track.id());
let tiers = track.tiers();
if current_level as usize >= tiers.len() {
return ViewResult::Message(
MessageType::Normal,
"Max level reached.".to_string(),
);
}
let next_tier = &tiers[current_level as usize];
if state.party_points >= next_tier.cost {
state.party_points -= next_tier.cost;
state.set_bonus_level(track.id(), current_level + 1);
ViewResult::Message(MessageType::Success, format!("Upgraded {}!", track.name()))
} else {
ViewResult::Message(MessageType::Error, "Not enough points.".to_string())
}
}
Action::Back => ViewResult::Navigate(Route::Store(StoreRoute::Grid)),
_ => ViewResult::None,
}
}
fn key_hints(&self) -> Vec<(&'static str, &'static str)> {
vec![
("↑↓", "select"),
("enter", "buy"),
("esc", "back"),
("q", "quit"),
]
}
}
fn format_cost(cost: u64) -> String {
if cost >= 1_000_000 {
format!("{}M P", cost / 1_000_000)
} else if cost >= 1_000 {
format!("{}K P", cost / 1_000)
} else {
format!("{} P", cost)
}
}
fn format_reward(reward: Reward) -> String {
match reward {
Reward::Multiplier(n) => format!("{}x", n),
Reward::FlatPoints(n) => format!("+{}", n),
}
}