use std::collections::{HashMap, HashSet};
use std::time::Duration;
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use futures::StreamExt;
use ratatui::widgets::ListState;
use ratatui::DefaultTerminal;
use tokio::sync::mpsc;
use crate::api;
use crate::api::ApiError;
use crate::cache;
use crate::i18n::Language;
use crate::models::{
AbilityInfo, EvolutionTree, PokemonDetail, PokemonEntry, Sprite, SpriteVariant,
};
use crate::query::Query;
use crate::team;
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub enum Message {
ListLoaded(Vec<PokemonEntry>),
PokemonLoaded {
detail: PokemonDetail,
evolution: EvolutionTree,
sprite: Option<Sprite>,
variant: SpriteVariant,
},
SpriteLoaded {
name: String,
variant: SpriteVariant,
sprite: Option<Sprite>,
},
AbilityLoaded(AbilityInfo),
TypeMembersLoaded {
type_name: String,
members: Vec<String>,
},
FlavorTranslated {
name: String,
lang: String,
text: String,
},
Error(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Search,
List,
Evolution,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortKey {
Dex,
Name,
}
impl SortKey {
pub fn next(self) -> Self {
match self {
SortKey::Dex => SortKey::Name,
SortKey::Name => SortKey::Dex,
}
}
}
pub struct App {
pub language: Language,
pub all_pokemon: Vec<PokemonEntry>,
pub filtered: Vec<usize>,
pub list_state: ListState,
pub query: String,
pub parsed_query: Query,
pub sort: SortKey,
pub type_members: HashMap<String, HashSet<String>>,
pub type_loading: HashSet<String>,
pub focus: Focus,
pub details: HashMap<String, PokemonDetail>,
pub evolutions: HashMap<String, EvolutionTree>,
pub sprites: HashMap<SpriteVariant, HashMap<String, Sprite>>,
pub sprite_loading: HashMap<SpriteVariant, HashSet<String>>,
pub sprite_variant: SpriteVariant,
pub evo_cursor: usize,
pub language_picker: bool,
pub lang_cursor: usize,
pub matchups: bool,
pub team: Vec<String>,
pub team_loading: HashSet<String>,
pub team_card: bool,
pub abilities: HashMap<String, AbilityInfo>,
pub ability_loading: HashSet<String>,
pub ability_card: bool,
pub help_card: bool,
pub translations: HashMap<(String, String), String>,
pub translating: HashSet<(String, String)>,
pub selected_name: Option<String>,
pub loading_detail: Option<String>,
pub list_loading: bool,
pub error: Option<String>,
pub spinner: usize,
pub should_quit: bool,
client: reqwest::Client,
tx: mpsc::Sender<Message>,
}
impl App {
pub fn new() -> anyhow::Result<(Self, mpsc::Receiver<Message>)> {
let client = api::build_client()?;
let (tx, rx) = mpsc::channel(64);
let app = App {
language: Language::English,
all_pokemon: Vec::new(),
filtered: Vec::new(),
list_state: ListState::default(),
query: String::new(),
parsed_query: Query::default(),
sort: SortKey::Dex,
type_members: HashMap::new(),
type_loading: HashSet::new(),
focus: Focus::List,
details: HashMap::new(),
evolutions: HashMap::new(),
sprites: HashMap::new(),
sprite_loading: HashMap::new(),
sprite_variant: SpriteVariant::Normal,
evo_cursor: 0,
language_picker: false,
lang_cursor: 0,
matchups: false,
team: Vec::new(),
team_loading: HashSet::new(),
team_card: false,
abilities: HashMap::new(),
ability_loading: HashSet::new(),
ability_card: false,
help_card: false,
translations: HashMap::new(),
translating: HashSet::new(),
selected_name: None,
loading_detail: None,
list_loading: false,
error: None,
spinner: 0,
should_quit: false,
client,
tx,
};
Ok((app, rx))
}
pub async fn run(
mut self,
mut terminal: DefaultTerminal,
mut rx: mpsc::Receiver<Message>,
) -> anyhow::Result<()> {
self.fetch_list();
let mut events = EventStream::new();
let mut ticker = tokio::time::interval(Duration::from_millis(120));
while !self.should_quit {
self.ensure_translation();
self.ensure_ability_info();
terminal.draw(|frame| crate::ui::render(frame, &mut self))?;
tokio::select! {
maybe_msg = rx.recv() => {
if let Some(msg) = maybe_msg {
self.handle_message(msg);
}
}
maybe_event = events.next() => {
match maybe_event {
Some(Ok(event)) => self.handle_event(event),
Some(Err(_)) => {} None => self.should_quit = true,
}
}
_ = ticker.tick() => {
self.spinner = self.spinner.wrapping_add(1);
}
}
}
Ok(())
}
fn fetch_list(&mut self) {
self.list_loading = true;
let tx = self.tx.clone();
let client = self.client.clone();
tokio::spawn(async move {
if let Some(cached) = cache::load_list().await {
let fresh = cached.fresh;
let _ = tx.send(Message::ListLoaded(cached.entries)).await;
if fresh {
return;
}
if let Ok(list) = api::fetch_pokemon_list(&client).await {
cache::store_list(&list).await;
let _ = tx.send(Message::ListLoaded(list)).await;
}
return;
}
let msg = match api::fetch_pokemon_list(&client).await {
Ok(list) => {
cache::store_list(&list).await;
Message::ListLoaded(list)
}
Err(err) => Message::Error(err.to_string()),
};
let _ = tx.send(msg).await;
});
}
fn request_missing_type_rosters(&mut self, query: &Query) {
let missing: Vec<String> = query
.types
.iter()
.filter(|t| !self.type_members.contains_key(*t) && !self.type_loading.contains(*t))
.cloned()
.collect();
for type_name in missing {
self.type_loading.insert(type_name.clone());
let tx = self.tx.clone();
let client = self.client.clone();
tokio::spawn(async move {
let members = resolve_type_members(&client, &type_name).await;
let _ = tx
.send(Message::TypeMembersLoaded { type_name, members })
.await;
});
}
}
fn request_selected(&mut self) {
let Some(name) = self.current_name() else {
return;
};
self.error = None;
self.selected_name = Some(name.clone());
if self.details.contains_key(&name) {
self.loading_detail = None;
self.ensure_visible_sprites();
return;
}
self.loading_detail = Some(name.clone());
let tx = self.tx.clone();
let client = self.client.clone();
let variant = self.sprite_variant;
tokio::spawn(async move {
let _ = tx.send(resolve_bundle(&client, &name, variant).await).await;
});
}
fn ensure_translation(&mut self) {
let code = self.language.flavor_code();
if code == "en" {
return; }
let (name, source) = {
let Some(detail) = self.selected_detail() else {
return;
};
if detail.flavors.contains_key(code) {
return; }
match detail.flavors.get("en") {
Some(src) => (detail.name.clone(), src.clone()),
None => return, }
};
let key = (name.clone(), code.to_string());
if self.translations.contains_key(&key) || self.translating.contains(&key) {
return;
}
self.translating.insert(key);
let tx = self.tx.clone();
let client = self.client.clone();
let lang = code.to_string();
tokio::spawn(async move {
if let Some(text) = cache::load_translation(&name, &lang).await {
let _ = tx
.send(Message::FlavorTranslated { name, lang, text })
.await;
return;
}
if let Ok(text) = api::translate_text(&client, &source, "en", &lang).await {
cache::store_translation(&name, &lang, &text).await;
let _ = tx
.send(Message::FlavorTranslated { name, lang, text })
.await;
}
});
}
pub fn translation_for(&self, name: &str, code: &str) -> Option<&str> {
self.translations
.get(&(name.to_string(), code.to_string()))
.map(String::as_str)
}
pub fn chain_names(&self) -> Vec<String> {
let mut names = Vec::new();
if let Some(tree) = self.selected_evolution() {
tree.collect_names(&mut names);
}
names
}
fn ensure_visible_sprites(&mut self) {
let variant = self.sprite_variant;
let names: Vec<String> = self
.selected_name
.iter()
.cloned()
.chain(self.chain_names())
.collect();
for name in names {
if self.sprite_for(&name).is_some() || self.sprite_is_loading(&name) {
continue;
}
let known_url = self
.details
.get(&name)
.map(|detail| detail.sprite_url_for(variant).map(str::to_string));
self.sprite_loading
.entry(variant)
.or_default()
.insert(name.clone());
let tx = self.tx.clone();
let client = self.client.clone();
tokio::spawn(async move {
let sprite = match known_url {
Some(url) => resolve_sprite(&client, &name, url.as_deref(), variant).await,
None => resolve_named_sprite(&client, &name, variant).await,
};
let _ = tx
.send(Message::SpriteLoaded {
name,
variant,
sprite,
})
.await;
});
}
}
fn toggle_shiny(&mut self) {
self.sprite_variant = self.sprite_variant.toggled();
self.ensure_visible_sprites();
}
pub fn sprite_for(&self, name: &str) -> Option<&Sprite> {
self.sprites.get(&self.sprite_variant)?.get(name)
}
pub fn sprite_is_loading(&self, name: &str) -> bool {
self.sprite_loading
.get(&self.sprite_variant)
.is_some_and(|pending| pending.contains(name))
}
fn remember_sprite(&mut self, name: String, variant: SpriteVariant, sprite: Sprite) {
self.sprites
.entry(variant)
.or_default()
.insert(name, sprite);
}
fn jump_to_evolution_member(&mut self) {
let names = self.chain_names();
let Some(name) = names.get(self.evo_cursor).cloned() else {
return;
};
self.query.clear();
self.recompute_filter();
if let Some(abs) = self.all_pokemon.iter().position(|p| p.name == name) {
if let Some(pos) = self.filtered.iter().position(|&i| i == abs) {
self.list_state.select(Some(pos));
}
}
self.request_selected();
}
fn handle_message(&mut self, msg: Message) {
match msg {
Message::ListLoaded(list) => {
self.all_pokemon = list;
self.list_loading = false;
self.recompute_filter();
if self.selected_name.is_none() {
self.request_selected();
}
}
Message::PokemonLoaded {
detail,
evolution,
sprite,
variant,
} => {
let name = detail.name.clone();
if self.loading_detail.as_deref() == Some(name.as_str()) {
self.loading_detail = None;
}
self.evolutions.insert(name.clone(), evolution);
if let Some(sprite) = sprite {
self.remember_sprite(name.clone(), variant, sprite);
}
let is_selected = self.selected_name.as_deref() == Some(name.as_str());
self.team_loading.remove(&name);
self.details.insert(name, detail);
if is_selected {
self.ensure_visible_sprites();
}
}
Message::SpriteLoaded {
name,
variant,
sprite,
} => {
if let Some(pending) = self.sprite_loading.get_mut(&variant) {
pending.remove(&name);
}
if let Some(sprite) = sprite {
self.remember_sprite(name, variant, sprite);
}
}
Message::AbilityLoaded(info) => {
self.ability_loading.remove(&info.name);
self.abilities.insert(info.name.clone(), info);
}
Message::TypeMembersLoaded { type_name, members } => {
self.type_loading.remove(&type_name);
self.type_members
.insert(type_name, members.into_iter().collect());
self.recompute_filter();
}
Message::FlavorTranslated { name, lang, text } => {
let key = (name, lang);
self.translating.remove(&key);
self.translations.insert(key, text);
}
Message::Error(err) => {
self.error = Some(err);
self.loading_detail = None;
self.list_loading = false;
}
}
}
fn handle_event(&mut self, event: Event) {
let Event::Key(key) = event else {
return; };
if key.kind != KeyEventKind::Press {
return;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
self.should_quit = true;
return;
}
if self.language_picker {
self.handle_language_key(key);
return;
}
if self.help_card {
if matches!(
key.code,
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?' | 'q' | 'Q')
) {
self.help_card = false;
}
return;
}
if self.ability_card {
if matches!(
key.code,
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('a' | 'A' | 'q' | 'Q')
) {
self.ability_card = false;
}
return;
}
if self.team_card {
if matches!(
key.code,
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('p' | 'P' | 'q' | 'Q')
) {
self.team_card = false;
}
return;
}
if self.matchups {
if matches!(
key.code,
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('t' | 'T' | 'q' | 'Q')
) {
self.matchups = false;
}
return;
}
match self.focus {
Focus::List => self.handle_list_key(key),
Focus::Search => self.handle_search_key(key),
Focus::Evolution => self.handle_evolution_key(key),
}
}
fn toggle_team_membership(&mut self) {
let Some(name) = self.current_name() else {
return;
};
if let Some(position) = self.team.iter().position(|member| *member == name) {
self.team.remove(position);
return;
}
if self.team.len() >= team::MAX_MEMBERS {
return; }
self.team.push(name.clone());
if self.details.contains_key(&name) || self.team_loading.contains(&name) {
return;
}
self.team_loading.insert(name.clone());
let tx = self.tx.clone();
let client = self.client.clone();
let variant = self.sprite_variant;
tokio::spawn(async move {
let _ = tx.send(resolve_bundle(&client, &name, variant).await).await;
});
}
pub fn team_details(&self) -> Vec<&PokemonDetail> {
self.team
.iter()
.filter_map(|name| self.details.get(name))
.collect()
}
pub fn is_in_team(&self, name: &str) -> bool {
self.team.iter().any(|member| member == name)
}
fn open_abilities(&mut self) {
if self.selected_detail().is_some() {
self.ability_card = true;
}
}
fn ensure_ability_info(&mut self) {
let missing: Vec<String> = {
let selection = self.selected_detail().into_iter();
selection
.chain(self.team_details())
.flat_map(|detail| detail.abilities.iter())
.map(|ability| ability.name.clone())
.filter(|name| {
!self.abilities.contains_key(name) && !self.ability_loading.contains(name)
})
.collect()
};
for name in missing {
self.ability_loading.insert(name.clone());
let tx = self.tx.clone();
let client = self.client.clone();
tokio::spawn(async move {
if let Some(info) = resolve_ability(&client, &name).await {
let _ = tx.send(Message::AbilityLoaded(info)).await;
}
});
}
}
fn open_matchups(&mut self) {
if self.selected_detail().is_some() {
self.matchups = true;
}
}
fn open_language_picker(&mut self) {
self.lang_cursor = self.language.index();
self.language_picker = true;
}
fn handle_language_key(&mut self, key: KeyEvent) {
let len = Language::ALL.len();
match key.code {
KeyCode::Esc => self.language_picker = false,
KeyCode::Up | KeyCode::Char('k') => {
self.lang_cursor = (self.lang_cursor + len - 1) % len;
}
KeyCode::Down | KeyCode::Char('j') => {
self.lang_cursor = (self.lang_cursor + 1) % len;
}
KeyCode::Enter | KeyCode::Char(' ') | KeyCode::Char('l') | KeyCode::Char('L') => {
self.language = Language::ALL[self.lang_cursor];
self.language_picker = false;
}
_ => {}
}
}
fn handle_list_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => self.should_quit = true,
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
KeyCode::PageUp => self.move_selection(-10),
KeyCode::PageDown => self.move_selection(10),
KeyCode::Enter => self.request_selected(),
KeyCode::Char('e') | KeyCode::Char('E') => self.focus_evolution(),
KeyCode::Char('t') | KeyCode::Char('T') => self.open_matchups(),
KeyCode::Tab | KeyCode::Char('/') => self.focus = Focus::Search,
KeyCode::Char('l') | KeyCode::Char('L') => self.open_language_picker(),
KeyCode::Char('s') | KeyCode::Char('S') => self.cycle_sort(),
KeyCode::Char(' ') => self.toggle_team_membership(),
KeyCode::Char('p') | KeyCode::Char('P') => self.team_card = true,
KeyCode::Char('a') | KeyCode::Char('A') => self.open_abilities(),
KeyCode::Char('x') | KeyCode::Char('X') => self.toggle_shiny(),
KeyCode::Char('?') => self.help_card = true,
_ => {}
}
}
fn focus_evolution(&mut self) {
let names = self.chain_names();
if names.is_empty() {
return; }
self.evo_cursor = self
.selected_name
.as_ref()
.and_then(|sel| names.iter().position(|n| n == sel))
.unwrap_or(0);
self.focus = Focus::Evolution;
}
fn handle_evolution_key(&mut self, key: KeyEvent) {
let len = self.chain_names().len();
match key.code {
KeyCode::Esc | KeyCode::Tab => self.focus = Focus::List,
KeyCode::Char('q') | KeyCode::Char('Q') => self.should_quit = true,
KeyCode::Left | KeyCode::Up | KeyCode::Char('h') | KeyCode::Char('k')
if self.evo_cursor > 0 =>
{
self.evo_cursor -= 1;
}
KeyCode::Right | KeyCode::Down | KeyCode::Char('l') | KeyCode::Char('j')
if self.evo_cursor + 1 < len =>
{
self.evo_cursor += 1;
}
KeyCode::Enter => self.jump_to_evolution_member(),
KeyCode::Char('t') | KeyCode::Char('T') => self.open_matchups(),
KeyCode::Char('x') | KeyCode::Char('X') => self.toggle_shiny(),
KeyCode::Char('?') => self.help_card = true,
_ => {}
}
}
fn handle_search_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Tab => self.focus = Focus::List,
KeyCode::Enter => {
self.request_selected();
self.focus = Focus::List;
}
KeyCode::Up => self.move_selection(-1),
KeyCode::Down => self.move_selection(1),
KeyCode::Backspace => {
self.query.pop();
self.recompute_filter();
}
KeyCode::Char(c) => {
self.query.push(c);
self.recompute_filter();
}
_ => {}
}
}
fn recompute_filter(&mut self) {
let query = Query::parse(&self.query);
self.request_missing_type_rosters(&query);
let anchor = self.current_name();
let mut filtered: Vec<usize> = self
.all_pokemon
.iter()
.enumerate()
.filter(|(_, p)| query.matches_entry(p) && self.has_every_type(&query, &p.name))
.map(|(idx, _)| idx)
.collect();
match self.sort {
SortKey::Dex => filtered.sort_unstable_by_key(|&idx| self.all_pokemon[idx].id),
SortKey::Name => {
filtered.sort_unstable_by(|&a, &b| {
self.all_pokemon[a].name.cmp(&self.all_pokemon[b].name)
});
}
}
self.filtered = filtered;
self.parsed_query = query;
self.restore_highlight(anchor);
}
fn has_every_type(&self, query: &Query, name: &str) -> bool {
query.types.iter().all(|type_name| {
self.type_members
.get(type_name)
.is_some_and(|members| members.contains(name))
})
}
fn restore_highlight(&mut self, anchor: Option<String>) {
if self.filtered.is_empty() {
self.list_state.select(None);
return;
}
let restored = anchor
.and_then(|name| self.all_pokemon.iter().position(|p| p.name == name))
.and_then(|abs| self.filtered.iter().position(|&idx| idx == abs));
self.list_state.select(Some(restored.unwrap_or(0)));
}
pub fn awaiting_type_roster(&self) -> bool {
self.parsed_query
.types
.iter()
.any(|type_name| !self.type_members.contains_key(type_name))
}
fn cycle_sort(&mut self) {
self.sort = self.sort.next();
self.recompute_filter();
}
fn move_selection(&mut self, delta: i32) {
if self.filtered.is_empty() {
return;
}
let len = self.filtered.len() as i32;
let current = self.list_state.selected().unwrap_or(0) as i32;
let next = (current + delta).rem_euclid(len);
self.list_state.select(Some(next as usize));
}
pub fn current_name(&self) -> Option<String> {
let selected = self.list_state.selected()?;
let idx = *self.filtered.get(selected)?;
self.all_pokemon.get(idx).map(|p| p.name.clone())
}
pub fn selected_detail(&self) -> Option<&PokemonDetail> {
let name = self.selected_name.as_ref()?;
self.details.get(name)
}
pub fn selected_evolution(&self) -> Option<&EvolutionTree> {
let name = self.selected_name.as_ref()?;
self.evolutions.get(name)
}
pub fn selected_sprite(&self) -> Option<&Sprite> {
self.sprite_for(self.selected_name.as_deref()?)
}
pub fn detail_is_loading(&self) -> bool {
match (&self.loading_detail, &self.selected_name) {
(Some(loading), Some(selected)) => loading == selected,
_ => false,
}
}
}
async fn resolve_bundle(client: &reqwest::Client, name: &str, variant: SpriteVariant) -> Message {
if let Some(bundle) = cache::load_bundle(name).await {
let sprite =
resolve_sprite(client, name, bundle.detail.sprite_url_for(variant), variant).await;
return Message::PokemonLoaded {
detail: bundle.detail,
evolution: bundle.evolution,
sprite,
variant,
};
}
match api::fetch_pokemon_bundle(client, name, variant).await {
Ok((detail, evolution, sprite)) => {
cache::store_bundle(name, &detail, &evolution).await;
record_sprite(name, sprite.as_ref(), variant).await;
Message::PokemonLoaded {
detail,
evolution,
sprite,
variant,
}
}
Err(err) => Message::Error(err.to_string()),
}
}
async fn resolve_sprite(
client: &reqwest::Client,
name: &str,
url: Option<&str>,
variant: SpriteVariant,
) -> Option<Sprite> {
if let Some(sprite) = cache::load_sprite(name, variant).await {
return Some(sprite);
}
if cache::has_sprite_answer(name, variant).await {
return None; }
let Some(url) = url else {
record_sprite(name, None, variant).await;
return None;
};
let sprite = api::fetch_sprite(client, url).await.ok();
record_sprite(name, sprite.as_ref(), variant).await;
sprite
}
async fn resolve_named_sprite(
client: &reqwest::Client,
name: &str,
variant: SpriteVariant,
) -> Option<Sprite> {
if let Some(sprite) = cache::load_sprite(name, variant).await {
return Some(sprite);
}
if cache::has_sprite_answer(name, variant).await {
return None;
}
let variety = resolve_default_variety(client, name).await?;
match api::fetch_named_sprite(client, &variety, variant).await {
Ok(sprite) => {
record_sprite(name, sprite.as_ref(), variant).await;
sprite
}
Err(ApiError::NotFound(_)) => {
record_sprite(name, None, variant).await;
None
}
Err(_) => None,
}
}
async fn resolve_default_variety(client: &reqwest::Client, name: &str) -> Option<String> {
if let Some(variety) = cache::load_default_variety(name).await {
return Some(variety);
}
let variety = match api::fetch_default_variety(client, name).await {
Ok(variety) => variety,
Err(ApiError::NotFound(_)) => name.to_string(),
Err(_) => return None,
};
cache::store_default_variety(name, &variety).await;
Some(variety)
}
async fn resolve_ability(client: &reqwest::Client, name: &str) -> Option<AbilityInfo> {
if let Some(info) = cache::load_ability(name).await {
return Some(info);
}
let info = api::fetch_ability(client, name).await.ok()?;
cache::store_ability(name, &info).await;
Some(info)
}
async fn resolve_type_members(client: &reqwest::Client, type_name: &str) -> Vec<String> {
if let Some(members) = cache::load_type_members(type_name).await {
return members;
}
match api::fetch_type_members(client, type_name).await {
Ok(members) => {
cache::store_type_members(type_name, &members).await;
members
}
Err(_) => Vec::new(),
}
}
async fn record_sprite(name: &str, sprite: Option<&Sprite>, variant: SpriteVariant) {
match sprite {
Some(sprite) => cache::store_sprite(name, sprite, variant).await,
None => cache::store_missing_sprite(name, variant).await,
}
}