use std::collections::{HashMap, HashSet};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use futures::StreamExt;
use ratatui::DefaultTerminal;
use tokio::sync::mpsc;
use tokio::time::MissedTickBehavior;
use crate::api;
use crate::api::ApiError;
use crate::browser::{Browser, SortKey};
use crate::cache;
use crate::cli::Startup;
use crate::color::{self, Depth};
use crate::i18n::Language;
use crate::models::{
AbilityInfo, EvolutionTree, LearnedMove, MoveInfo, PokemonDetail, PokemonEntry, RosterTerm,
Sprite, SpriteVariant,
};
use crate::session::{self, Session};
use crate::team;
use crate::theme::{self, Theme};
const MOVE_BAND: usize = 36;
const MOVE_LOOKBEHIND: usize = 4;
const SPINNER_TICK: Duration = Duration::from_millis(120);
#[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),
MoveLoaded(MoveInfo),
RosterLoaded {
term: RosterTerm,
members: Vec<String>,
},
FlavorTranslated {
name: String,
lang: String,
text: String,
},
Error(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Search,
List,
Evolution,
}
pub struct App {
pub language: Language,
pub theme: Theme,
pub browser: Browser,
pub roster_loading: HashSet<RosterTerm>,
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 evo_card: bool,
pub language_picker: bool,
pub lang_cursor: usize,
pub matchups: bool,
pub pin: Option<String>,
pub compare_card: bool,
pub team: Vec<String>,
pub team_loading: HashSet<String>,
pub team_card: bool,
pub team_cursor: usize,
pub abilities: HashMap<String, AbilityInfo>,
pub ability_loading: HashSet<String>,
pub ability_card: bool,
pub moves: HashMap<String, MoveInfo>,
pub move_loading: HashSet<String>,
pub moves_card: bool,
pub move_cursor: usize,
pub forms_card: bool,
pub forms_cursor: usize,
pub help_card: bool,
pub translations: HashMap<(String, String), String>,
pub translating: HashSet<(String, String)>,
pub selected_name: Option<String>,
pub color_depth: Depth,
cli_language: Option<Language>,
cli_theme: Option<Theme>,
startup_species: 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(startup: Startup) -> anyhow::Result<(Self, mpsc::Receiver<Message>)> {
let client = api::build_client()?;
let color_depth = color::resolve(startup.color, &color::Env::from_process());
color::enforce(startup.color, color_depth);
let (tx, rx) = mpsc::channel(64);
let app = App {
language: startup.language.unwrap_or(Language::English),
theme: startup.theme.unwrap_or_default(),
browser: Browser::default(),
roster_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,
evo_card: false,
language_picker: false,
lang_cursor: 0,
matchups: false,
pin: None,
compare_card: false,
team: Vec::new(),
team_loading: HashSet::new(),
team_card: false,
team_cursor: 0,
abilities: HashMap::new(),
ability_loading: HashSet::new(),
ability_card: false,
moves: HashMap::new(),
move_loading: HashSet::new(),
moves_card: false,
move_cursor: 0,
forms_card: false,
forms_cursor: 0,
help_card: false,
translations: HashMap::new(),
translating: HashSet::new(),
selected_name: None,
color_depth,
cli_language: startup.language,
cli_theme: startup.theme,
startup_species: startup.species,
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.restore(session::load().await);
theme::use_theme(self.theme);
self.fetch_list();
let mut events = EventStream::new();
let mut ticker = tokio::time::interval(SPINNER_TICK);
ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
while !self.should_quit {
self.ensure_translation();
self.ensure_ability_info();
self.ensure_move_info();
let depth = self.color_depth;
terminal.draw(|frame| {
crate::ui::render(frame, &mut self);
color::degrade(frame.buffer_mut(), depth);
})?;
let animating = self.is_busy();
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(), if animating => {
self.spinner = self.spinner.wrapping_add(1);
}
}
}
session::store(&self.snapshot()).await;
Ok(())
}
pub fn is_busy(&self) -> bool {
self.list_loading
|| self.loading_detail.is_some()
|| !self.roster_loading.is_empty()
|| !self.ability_loading.is_empty()
|| !self.move_loading.is_empty()
|| !self.translating.is_empty()
|| !self.team_loading.is_empty()
|| self
.sprite_loading
.values()
.any(|pending| !pending.is_empty())
}
fn snapshot(&self) -> Session {
Session {
team: self.team.clone(),
language: Some(self.language.flavor_code().to_string()),
sort: Some(self.browser.sort.code().to_string()),
theme: Some(self.theme.code().to_string()),
shiny: self.sprite_variant.is_shiny(),
}
}
fn restore(&mut self, session: Session) {
if self.cli_language.is_none() {
if let Some(language) = session.language.as_deref().and_then(Language::from_code) {
self.language = language;
}
}
if let Some(sort) = session.sort.as_deref().and_then(SortKey::from_code) {
self.browser.sort = sort;
}
if self.cli_theme.is_none() {
if let Some(theme) = session.theme.as_deref().and_then(Theme::from_code) {
self.theme = theme;
}
}
if session.shiny {
self.sprite_variant = SpriteVariant::Shiny;
}
self.team = session.team;
for name in self.team.clone() {
self.request_team_member(name);
}
}
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_rosters(&mut self) {
let missing: Vec<RosterTerm> = self
.browser
.parsed
.rosters
.iter()
.filter(|t| !self.browser.rosters.contains_key(*t) && !self.roster_loading.contains(*t))
.cloned()
.collect();
for term in missing {
self.roster_loading.insert(term.clone());
let tx = self.tx.clone();
let client = self.client.clone();
tokio::spawn(async move {
let members = resolve_roster(&client, &term).await;
let _ = tx.send(Message::RosterLoaded { term, 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) {
if self.color_depth == Depth::None {
return;
}
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> {
if self.color_depth == Depth::None {
return None;
}
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 select_named_species(&mut self, name: String) {
self.browser.query = name.trim().to_lowercase();
self.recompute_filter();
let exact = self.browser.position_of(&self.browser.query.clone());
if let Some(pos) = exact {
self.browser.list_state.select(Some(pos));
}
}
fn jump_to_evolution_member(&mut self) {
let names = self.chain_names();
let Some(name) = names.get(self.evo_cursor).cloned() else {
return;
};
self.browser.query.clear();
self.recompute_filter();
if let Some(pos) = self.browser.position_of(&name) {
self.browser.list_state.select(Some(pos));
}
self.request_selected();
}
fn handle_message(&mut self, msg: Message) {
match msg {
Message::ListLoaded(list) => {
self.browser.all = list;
self.list_loading = false;
if let Some(name) = self.startup_species.take() {
self.select_named_species(name);
} else {
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::MoveLoaded(info) => {
self.move_loading.remove(&info.name);
self.moves.insert(info.name.clone(), info);
}
Message::RosterLoaded { term, members } => {
self.roster_loading.remove(&term);
self.browser
.rosters
.insert(term, 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.moves_card {
self.handle_moves_key(key);
return;
}
if self.team_card {
self.handle_team_key(key);
return;
}
if self.forms_card {
self.handle_forms_key(key);
return;
}
if self.matchups {
if matches!(
key.code,
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('t' | 'T' | 'q' | 'Q')
) {
self.matchups = false;
}
return;
}
if self.compare_card {
if matches!(
key.code,
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('c' | 'C' | 'q' | 'Q')
) {
self.compare_card = false;
}
return;
}
if self.evo_card {
self.handle_evo_card_key(key);
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());
self.request_team_member(name);
}
fn request_team_member(&mut self, name: String) {
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_team_card(&mut self) {
self.team_cursor = 0;
self.team_card = true;
}
fn handle_team_key(&mut self, key: KeyEvent) {
let len = self.team.len();
match key.code {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('p' | 'P' | 'q' | 'Q') => {
self.team_card = false;
}
KeyCode::Up | KeyCode::Char('k') if len > 0 => {
self.team_cursor = (self.team_cursor + len - 1) % len;
}
KeyCode::Down | KeyCode::Char('j') if len > 0 => {
self.team_cursor = (self.team_cursor + 1) % len;
}
KeyCode::Char('c') | KeyCode::Char('C') => self.pin_or_compare_member(),
_ => {}
}
}
fn pin_or_compare_member(&mut self) {
let Some(name) = self.team.get(self.team_cursor).cloned() else {
return; };
if !self.details.contains_key(&name) {
return; }
match self.pin.as_deref() {
Some(pinned) if pinned == name => self.pin = None,
None => self.pin = Some(name),
Some(_) => {
if self.show_species(&name) {
self.team_card = false;
self.compare_card = true;
}
}
}
}
fn show_species(&mut self, name: &str) -> bool {
if self.browser.position_of(name).is_none() {
self.browser.query.clear();
self.recompute_filter();
}
let Some(pos) = self.browser.position_of(name) else {
return false;
};
self.browser.list_state.select(Some(pos));
self.request_selected();
true
}
pub fn forms(&self) -> &[String] {
self.selected_detail()
.map(|detail| detail.forms.as_slice())
.unwrap_or_default()
}
fn open_forms(&mut self) {
let Some(detail) = self.selected_detail() else {
return; };
if detail.forms.len() < 2 {
return;
}
let cursor = detail
.forms
.iter()
.position(|form| *form == detail.name)
.unwrap_or(0);
self.forms_cursor = cursor;
self.forms_card = true;
}
fn handle_forms_key(&mut self, key: KeyEvent) {
let len = self.forms().len();
match key.code {
KeyCode::Esc | KeyCode::Char('v' | 'V' | 'q' | 'Q') => self.forms_card = false,
KeyCode::Up | KeyCode::Char('k') if len > 0 => {
self.forms_cursor = (self.forms_cursor + len - 1) % len;
}
KeyCode::Down | KeyCode::Char('j') if len > 0 => {
self.forms_cursor = (self.forms_cursor + 1) % len;
}
KeyCode::Enter => self.jump_to_form(),
_ => {}
}
}
fn jump_to_form(&mut self) {
let Some(name) = self.forms().get(self.forms_cursor).cloned() else {
return;
};
if self.show_species(&name) {
self.forms_card = false;
}
}
fn open_abilities(&mut self) {
if self.selected_detail().is_some() {
self.ability_card = true;
}
}
fn open_moves(&mut self) {
if self.selected_learnset().is_some_and(|set| !set.is_empty()) {
self.moves_card = true;
self.move_cursor = 0;
}
}
pub fn selected_learnset(&self) -> Option<&[LearnedMove]> {
self.selected_detail().map(|detail| detail.moves.as_slice())
}
pub fn highlighted_move(&self) -> Option<&LearnedMove> {
self.selected_learnset()?.get(self.move_cursor)
}
fn handle_moves_key(&mut self, key: KeyEvent) {
let len = self.selected_learnset().map_or(0, <[LearnedMove]>::len);
match key.code {
KeyCode::Esc | KeyCode::Char('m' | 'M' | 'q' | 'Q') => self.moves_card = false,
KeyCode::Up | KeyCode::Char('k') => self.move_move_cursor(-1, len),
KeyCode::Down | KeyCode::Char('j') => self.move_move_cursor(1, len),
KeyCode::PageUp => self.move_move_cursor(-10, len),
KeyCode::PageDown => self.move_move_cursor(10, len),
KeyCode::Home => self.move_cursor = 0,
KeyCode::End => self.move_cursor = len.saturating_sub(1),
_ => {}
}
}
fn move_move_cursor(&mut self, delta: i32, len: usize) {
if len == 0 {
return;
}
let next = self.move_cursor as i32 + delta;
self.move_cursor = next.clamp(0, len as i32 - 1) as usize;
}
fn ensure_move_info(&mut self) {
if !self.moves_card {
return;
}
let Some(learnset) = self.selected_learnset() else {
return;
};
let start = self.move_cursor.saturating_sub(MOVE_LOOKBEHIND);
let missing: Vec<String> = learnset
.iter()
.skip(start)
.take(MOVE_BAND)
.map(|learned| learned.name.clone())
.filter(|name| !self.moves.contains_key(name) && !self.move_loading.contains(name))
.collect();
for name in missing {
self.move_loading.insert(name.clone());
let tx = self.tx.clone();
let client = self.client.clone();
tokio::spawn(async move {
if let Some(info) = resolve_move(&client, &name).await {
let _ = tx.send(Message::MoveLoaded(info)).await;
}
});
}
}
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 pin_or_compare(&mut self) {
let Some(current) = self.selected_name.clone() else {
return; };
if !self.details.contains_key(¤t) {
return; }
match self.pin.as_deref() {
Some(pinned) if pinned == current => self.pin = None,
Some(_) => self.compare_card = true,
None => self.pin = Some(current),
}
}
pub fn is_pinned(&self, name: &str) -> bool {
self.pin.as_deref() == Some(name)
}
pub fn comparison(&self) -> Option<(&PokemonDetail, &PokemonDetail)> {
let pinned = self.details.get(self.pin.as_ref()?)?;
let current = self.selected_detail()?;
Some((pinned, current))
}
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('f') | KeyCode::Char('F') => self.open_evolution_card(),
KeyCode::Char('t') | KeyCode::Char('T') => self.open_matchups(),
KeyCode::Char('c') | KeyCode::Char('C') => self.pin_or_compare(),
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.open_team_card(),
KeyCode::Char('a') | KeyCode::Char('A') => self.open_abilities(),
KeyCode::Char('m') | KeyCode::Char('M') => self.open_moves(),
KeyCode::Char('v') | KeyCode::Char('V') => self.open_forms(),
KeyCode::Char('x') | KeyCode::Char('X') => self.toggle_shiny(),
KeyCode::Char('r') | KeyCode::Char('R') => self.open_random(),
KeyCode::Char('?') => self.help_card = true,
_ => {}
}
}
fn open_random(&mut self) {
let Some(pos) = random_index(self.browser.filtered.len()) else {
return; };
self.browser.list_state.select(Some(pos));
self.request_selected();
}
fn focus_evolution(&mut self) {
if self.park_evo_cursor() {
self.focus = Focus::Evolution;
}
}
fn open_evolution_card(&mut self) {
if self.park_evo_cursor() {
self.evo_card = true;
}
}
fn park_evo_cursor(&mut self) -> bool {
let names = self.chain_names();
if names.is_empty() {
return false; }
self.evo_cursor = self
.selected_name
.as_ref()
.and_then(|sel| names.iter().position(|n| n == sel))
.unwrap_or(0);
true
}
fn handle_evo_card_key(&mut self, key: KeyEvent) {
let len = self.chain_names().len();
match key.code {
KeyCode::Esc | KeyCode::Char('f' | 'F' | 'q' | 'Q') => self.evo_card = false,
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();
self.evo_card = false;
}
KeyCode::Char('x') | KeyCode::Char('X') => self.toggle_shiny(),
KeyCode::Char('?') => self.help_card = true,
_ => {}
}
}
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('f') | KeyCode::Char('F') => self.evo_card = true,
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.browser.query.pop();
self.recompute_filter();
}
KeyCode::Char(c) => {
self.browser.query.push(c);
self.recompute_filter();
}
_ => {}
}
}
fn recompute_filter(&mut self) {
self.browser.recompute();
self.request_missing_rosters();
}
pub fn awaiting_roster(&self) -> bool {
self.browser.awaiting_roster()
}
fn cycle_sort(&mut self) {
self.browser.cycle_sort();
}
fn move_selection(&mut self, delta: i32) {
self.browser.move_selection(delta);
}
pub fn current_name(&self) -> Option<String> {
self.browser.current_name()
}
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_move(client: &reqwest::Client, name: &str) -> Option<MoveInfo> {
if let Some(info) = cache::load_move(name).await {
return Some(info);
}
let info = api::fetch_move(client, name).await.ok()?;
cache::store_move(name, &info).await;
Some(info)
}
async fn resolve_roster(client: &reqwest::Client, term: &RosterTerm) -> Vec<String> {
if let Some(members) = cache::load_roster(term).await {
return members;
}
match api::fetch_roster(client, term).await {
Ok(members) => {
cache::store_roster(term, &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,
}
}
fn random_index(len: usize) -> Option<usize> {
if len == 0 {
return None;
}
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.subsec_nanos())
.unwrap_or(0);
Some(nanos as usize % len)
}
#[cfg(test)]
pub(crate) fn app_listing(entries: &[(u32, &str)]) -> App {
let (mut app, _rx) = App::new(Startup::default()).expect("client builds");
app.browser.all = entries
.iter()
.map(|&(id, name)| PokemonEntry {
name: name.to_string(),
id,
})
.collect();
app
}
#[cfg(test)]
pub(crate) fn loaded(name: &str) -> PokemonDetail {
PokemonDetail {
name: name.to_string(),
species: name.to_string(),
forms: Vec::new(),
dex_number: 0,
is_legendary: false,
is_mythical: false,
is_baby: false,
types: Vec::new(),
abilities: Vec::new(),
stats: Vec::new(),
height: 0,
weight: 0,
sprite_url: None,
shiny_sprite_url: None,
genera: std::collections::HashMap::new(),
flavors: std::collections::HashMap::new(),
moves: Vec::new(),
learnset_games: None,
field: crate::models::FieldData::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::RosterKind;
use crate::query::Query;
fn members(names: &[&str]) -> HashSet<String> {
names.iter().map(|n| n.to_string()).collect()
}
fn visible(app: &App) -> Vec<&str> {
app.browser
.filtered
.iter()
.map(|&idx| app.browser.all[idx].name.as_str())
.collect()
}
fn line(names: &[&str]) -> EvolutionTree {
let mut node = EvolutionTree {
name: names[names.len() - 1].to_string(),
condition: None,
children: Vec::new(),
};
for name in names.iter().rev().skip(1) {
node = EvolutionTree {
name: (*name).to_string(),
condition: None,
children: vec![node],
};
}
node
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn taking_a_stage_collapses_the_full_screen_chain() {
let mut app = app_listing(&[(1, "bulbasaur"), (2, "ivysaur")]);
app.color_depth = Depth::None;
app.recompute_filter();
app.details
.insert("bulbasaur".to_string(), loaded("bulbasaur"));
app.details.insert("ivysaur".to_string(), loaded("ivysaur"));
app.selected_name = Some("bulbasaur".to_string());
app.evolutions
.insert("bulbasaur".to_string(), line(&["bulbasaur", "ivysaur"]));
app.open_evolution_card();
assert!(app.evo_card);
assert_eq!(app.evo_cursor, 0);
app.handle_evo_card_key(press(KeyCode::Right));
app.handle_evo_card_key(press(KeyCode::Enter));
assert_eq!(app.selected_name.as_deref(), Some("ivysaur"));
assert!(!app.evo_card);
}
#[test]
fn the_comparison_key_pins_then_compares_then_lets_go() {
let mut app = app_listing(&[(94, "gengar"), (65, "alakazam")]);
app.recompute_filter();
app.details.insert("gengar".to_string(), loaded("gengar"));
app.details
.insert("alakazam".to_string(), loaded("alakazam"));
app.selected_name = Some("gengar".to_string());
app.pin_or_compare();
assert!(app.is_pinned("gengar"));
assert!(!app.compare_card);
app.selected_name = Some("alakazam".to_string());
app.pin_or_compare();
assert!(app.compare_card);
let (left, right) = app.comparison().expect("two loaded records");
assert_eq!(
(left.name.as_str(), right.name.as_str()),
("gengar", "alakazam")
);
app.compare_card = false;
app.selected_name = Some("gengar".to_string());
app.pin_or_compare();
assert!(!app.is_pinned("gengar"));
assert!(!app.compare_card);
}
#[test]
fn the_comparison_key_on_the_party_card_pins_then_compares_then_lets_go() {
let mut app = app_listing(&[(94, "gengar"), (65, "alakazam")]);
app.color_depth = Depth::None;
app.recompute_filter();
app.details.insert("gengar".to_string(), loaded("gengar"));
app.details
.insert("alakazam".to_string(), loaded("alakazam"));
app.team = vec!["gengar".to_string(), "alakazam".to_string()];
app.open_team_card();
app.handle_team_key(press(KeyCode::Char('c')));
assert!(app.is_pinned("gengar"));
assert!(!app.compare_card);
assert!(app.team_card, "pinning alone does not close the card");
app.handle_team_key(press(KeyCode::Down));
app.handle_team_key(press(KeyCode::Char('c')));
assert!(app.compare_card);
assert!(!app.team_card, "the comparison takes the card's place");
assert_eq!(app.selected_name.as_deref(), Some("alakazam"));
let (left, right) = app.comparison().expect("two loaded records");
assert_eq!(
(left.name.as_str(), right.name.as_str()),
("gengar", "alakazam")
);
app.compare_card = false;
app.open_team_card();
app.handle_team_key(press(KeyCode::Char('c')));
assert!(!app.is_pinned("gengar"));
assert!(!app.compare_card);
}
#[test]
fn a_party_member_still_loading_is_not_pinned_from_the_card() {
let mut app = app_listing(&[(94, "gengar")]);
app.recompute_filter();
app.team = vec!["gengar".to_string()];
app.open_team_card();
app.handle_team_key(press(KeyCode::Char('c')));
assert!(app.pin.is_none());
}
#[test]
fn the_party_cursor_wraps_and_an_empty_party_has_nowhere_to_put_it() {
let mut app = app_listing(&[]);
app.team = vec!["a".to_string(), "b".to_string(), "c".to_string()];
app.open_team_card();
app.handle_team_key(press(KeyCode::Up));
assert_eq!(app.team_cursor, 2);
app.handle_team_key(press(KeyCode::Down));
assert_eq!(app.team_cursor, 0);
app.team.clear();
app.open_team_card();
app.handle_team_key(press(KeyCode::Down));
assert_eq!(app.team_cursor, 0);
app.handle_team_key(press(KeyCode::Char('c')));
assert!(app.pin.is_none());
}
#[test]
fn showing_a_species_the_filter_hides_clears_the_box_and_a_filter_it_passes_is_kept() {
let mut app = app_listing(&[(94, "gengar"), (65, "alakazam")]);
app.color_depth = Depth::None;
app.details.insert("gengar".to_string(), loaded("gengar"));
app.details
.insert("alakazam".to_string(), loaded("alakazam"));
app.browser.query = "gen".to_string();
app.recompute_filter();
assert!(app.show_species("gengar"));
assert_eq!(
app.browser.query, "gen",
"gengar matches, so the filter stays"
);
assert_eq!(app.selected_name.as_deref(), Some("gengar"));
assert!(app.show_species("alakazam"));
assert_eq!(
app.browser.query, "",
"alakazam did not, so the box was cleared"
);
assert_eq!(app.selected_name.as_deref(), Some("alakazam"));
assert!(!app.show_species("missingno"), "not in the list at all");
}
fn with_forms(name: &str, species: &str, forms: &[&str]) -> PokemonDetail {
PokemonDetail {
species: species.to_string(),
forms: forms.iter().map(|f| f.to_string()).collect(),
..loaded(name)
}
}
fn app_with_raichu() -> App {
let mut app = app_listing(&[(26, "raichu"), (10100, "raichu-alola")]);
app.color_depth = Depth::None;
app.recompute_filter();
for name in ["raichu", "raichu-alola"] {
app.details.insert(
name.to_string(),
with_forms(name, "raichu", &["raichu", "raichu-alola"]),
);
}
assert!(app.show_species("raichu"));
app
}
#[test]
fn the_forms_card_opens_on_the_form_shown_and_jumps_to_the_one_picked() {
let mut app = app_with_raichu();
app.open_forms();
assert!(app.forms_card);
assert_eq!(app.forms_cursor, 0);
app.handle_forms_key(press(KeyCode::Down));
app.handle_forms_key(press(KeyCode::Enter));
assert_eq!(app.selected_name.as_deref(), Some("raichu-alola"));
assert!(!app.forms_card);
app.open_forms();
assert_eq!(app.forms_cursor, 1);
app.handle_forms_key(press(KeyCode::Up));
app.handle_forms_key(press(KeyCode::Enter));
assert_eq!(app.selected_name.as_deref(), Some("raichu"));
}
#[test]
fn a_species_with_one_variety_has_no_card_to_open() {
let mut app = app_listing(&[(483, "dialga")]);
app.color_depth = Depth::None;
app.recompute_filter();
app.details.insert(
"dialga".to_string(),
with_forms("dialga", "dialga", &["dialga"]),
);
assert!(app.show_species("dialga"));
app.open_forms();
assert!(!app.forms_card);
let mut empty = app_listing(&[(1, "bulbasaur")]);
empty.open_forms();
assert!(!empty.forms_card);
assert!(empty.forms().is_empty());
}
#[test]
fn the_forms_cursor_wraps_at_both_ends() {
let mut app = app_with_raichu();
app.open_forms();
app.handle_forms_key(press(KeyCode::Up));
assert_eq!(app.forms_cursor, 1);
app.handle_forms_key(press(KeyCode::Down));
assert_eq!(app.forms_cursor, 0);
app.handle_forms_key(press(KeyCode::Char('v')));
assert!(!app.forms_card);
}
#[test]
fn a_roll_lands_inside_the_current_filter_and_never_outside_it() {
let mut app = app_listing(&[
(92, "gastly"),
(93, "haunter"),
(94, "gengar"),
(25, "pikachu"),
]);
app.color_depth = Depth::None;
app.browser.rosters.insert(
RosterTerm::new(RosterKind::Type, "ghost"),
members(&["gastly", "haunter", "gengar"]),
);
for name in ["gastly", "haunter", "gengar", "pikachu"] {
app.details.insert(name.to_string(), loaded(name));
}
app.browser.query = "type:ghost".to_string();
app.recompute_filter();
for _ in 0..50 {
app.open_random();
let landed = app.selected_name.clone().expect("a roll loads something");
assert!(
["gastly", "haunter", "gengar"].contains(&landed.as_str()),
"{landed} is not in the filter"
);
let pos = app.browser.list_state.selected().expect("the cursor moved");
assert!(
pos < app.browser.filtered.len(),
"the cursor is inside the filtered list"
);
}
}
#[test]
fn rolling_on_an_empty_list_does_nothing() {
let mut app = app_listing(&[(1, "bulbasaur")]);
app.browser.query = "nothing-matches-this".to_string();
app.recompute_filter();
assert!(app.browser.filtered.is_empty());
app.open_random();
assert_eq!(
app.browser.list_state.selected(),
None,
"the cursor stays parked"
);
assert_eq!(app.selected_name, None, "nothing was loaded");
}
#[test]
fn a_roll_over_one_row_always_lands_on_it() {
assert_eq!(random_index(1), Some(0));
assert_eq!(random_index(0), None);
let pos = random_index(7).expect("a non-empty list has a position");
assert!(pos < 7);
}
#[test]
fn a_species_still_loading_is_not_pinned() {
let mut app = app_listing(&[(94, "gengar")]);
app.recompute_filter();
app.selected_name = Some("gengar".to_string());
app.pin_or_compare();
assert!(app.pin.is_none());
}
#[test]
fn the_full_screen_chain_does_not_open_on_a_species_with_no_chain_loaded() {
let mut app = app_listing(&[(1, "bulbasaur")]);
app.recompute_filter();
app.selected_name = Some("bulbasaur".to_string());
app.open_evolution_card();
assert!(!app.evo_card);
}
#[test]
fn roster_terms_of_different_kinds_narrow_together() {
let mut app = app_listing(&[(1, "bulbasaur"), (43, "oddish"), (92, "gastly")]);
app.browser.rosters.insert(
RosterTerm::new(RosterKind::Type, "poison"),
members(&["bulbasaur", "oddish", "gastly"]),
);
app.browser.rosters.insert(
RosterTerm::new(RosterKind::EggGroup, "plant"),
members(&["bulbasaur", "oddish"]),
);
app.browser.query = "type:poison".to_string();
app.recompute_filter();
assert_eq!(visible(&app), ["bulbasaur", "oddish", "gastly"]);
app.browser.query = "type:poison egg:grass".to_string();
app.recompute_filter();
assert_eq!(visible(&app), ["bulbasaur", "oddish"]);
}
#[test]
fn the_list_waits_until_every_roster_it_needs_has_landed() {
let mut app = app_listing(&[]);
app.browser.parsed = Query::parse("type:poison ability:levitate");
assert!(app.awaiting_roster());
app.browser
.rosters
.insert(RosterTerm::new(RosterKind::Type, "poison"), HashSet::new());
assert!(app.awaiting_roster());
app.browser.rosters.insert(
RosterTerm::new(RosterKind::Ability, "levitate"),
HashSet::new(),
);
assert!(!app.awaiting_roster());
}
#[test]
fn the_moves_cursor_stops_at_both_ends_of_the_learnset() {
let mut app = app_listing(&[]);
app.move_move_cursor(-1, 3);
assert_eq!(app.move_cursor, 0);
app.move_move_cursor(10, 3);
assert_eq!(app.move_cursor, 2);
app.move_cursor = 0;
app.move_move_cursor(1, 0);
assert_eq!(app.move_cursor, 0);
}
#[test]
fn an_app_with_nothing_in_flight_is_idle() {
assert!(!app_listing(&[]).is_busy());
}
#[test]
fn any_one_pending_request_is_enough_to_keep_the_spinner_turning() {
type Pending = (&'static str, fn(&mut App));
let cases: [Pending; 7] = [
("list", |app| app.list_loading = true),
("detail", |app| app.loading_detail = Some("mew".to_string())),
("roster", |app| {
app.roster_loading
.insert(RosterTerm::new(RosterKind::Type, "ghost"));
}),
("ability", |app| {
app.ability_loading.insert("levitate".to_string());
}),
("translation", |app| {
app.translating
.insert(("mew".to_string(), "tr".to_string()));
}),
("party member", |app| {
app.team_loading.insert("mew".to_string());
}),
("sprite", |app| {
app.sprite_loading
.entry(SpriteVariant::Normal)
.or_default()
.insert("mew".to_string());
}),
];
for (kind, begin) in cases {
let mut app = app_listing(&[]);
begin(&mut app);
assert!(
app.is_busy(),
"a pending {kind} request should read as busy"
);
}
}
#[test]
fn a_pending_set_that_has_drained_stops_counting() {
let mut app = app_listing(&[]);
app.sprite_loading.entry(SpriteVariant::Normal).or_default();
assert!(!app.is_busy());
}
#[test]
fn a_named_species_wins_the_cursor_over_the_longer_name_that_sorts_first() {
let mut app = app_listing(&[(150, "mewtwo"), (151, "mew")]);
app.select_named_species("Mew".to_string());
assert_eq!(
app.browser.query, "mew",
"the box shows what narrowed the list"
);
assert_eq!(
app.browser.filtered.len(),
2,
"Mewtwo still matches the text"
);
assert_eq!(app.current_name().as_deref(), Some("mew"));
}
#[test]
fn a_name_reaching_the_box_carries_its_search_syntax_with_it() {
let mut app = app_listing(&[(25, "pikachu"), (26, "raichu")]);
app.select_named_species("dex:26".to_string());
assert_eq!(app.current_name().as_deref(), Some("raichu"));
}
#[test]
fn a_name_matching_nothing_lands_on_the_empty_list_rather_than_an_error() {
let mut app = app_listing(&[(1, "bulbasaur")]);
app.select_named_species("gengr".to_string());
assert!(app.browser.filtered.is_empty());
assert_eq!(app.current_name(), None, "nothing to load, nothing loaded");
assert_eq!(
app.browser.query, "gengr",
"and the box says why the list is empty"
);
}
#[test]
fn a_lang_flag_outranks_the_language_the_last_run_left_behind() {
let (mut app, _rx) = App::new(Startup {
language: Some(Language::Turkish),
..Startup::default()
})
.expect("client builds");
app.restore(Session {
language: Some("de".to_string()),
..Session::default()
});
assert_eq!(app.language, Language::Turkish);
}
#[test]
fn without_the_flag_the_stored_language_is_still_what_comes_back() {
let (mut app, _rx) = App::new(Startup::default()).expect("client builds");
app.restore(Session {
language: Some("de".to_string()),
..Session::default()
});
assert_eq!(app.language, Language::German);
}
#[test]
fn a_theme_flag_outranks_the_palette_the_last_run_left_behind() {
let (mut app, _rx) = App::new(Startup {
theme: Some(Theme::Pico8),
..Startup::default()
})
.expect("client builds");
app.restore(Session {
theme: Some("dmg".to_string()),
..Session::default()
});
assert_eq!(app.theme, Theme::Pico8);
let (mut app, _rx) = App::new(Startup::default()).expect("client builds");
app.restore(Session {
theme: Some("dmg".to_string()),
..Session::default()
});
assert_eq!(app.theme, Theme::Dmg);
assert_eq!(
app.snapshot().theme.as_deref(),
Some("dmg"),
"and is what this run hands on"
);
}
#[test]
fn a_session_naming_a_palette_this_build_does_not_have_keeps_the_default() {
let (mut app, _rx) = App::new(Startup::default()).expect("client builds");
app.restore(Session {
theme: Some("cga".to_string()),
..Session::default()
});
assert_eq!(app.theme, Theme::default());
}
}