use std::collections::{HashMap, HashSet};
use ratatui::widgets::ListState;
use crate::models::{PokemonEntry, RosterTerm};
use crate::query::Query;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SortKey {
#[default]
Dex,
Name,
}
impl SortKey {
pub fn next(self) -> Self {
match self {
SortKey::Dex => SortKey::Name,
SortKey::Name => SortKey::Dex,
}
}
pub fn code(self) -> &'static str {
match self {
SortKey::Dex => "dex",
SortKey::Name => "name",
}
}
pub fn from_code(code: &str) -> Option<Self> {
match code {
"dex" => Some(SortKey::Dex),
"name" => Some(SortKey::Name),
_ => None,
}
}
}
#[derive(Default)]
pub struct Browser {
pub all: Vec<PokemonEntry>,
pub filtered: Vec<usize>,
pub list_state: ListState,
pub query: String,
pub parsed: Query,
pub sort: SortKey,
pub rosters: HashMap<RosterTerm, HashSet<String>>,
}
impl Browser {
pub fn recompute(&mut self) {
let query = Query::parse(&self.query);
let anchor = self.current_name();
let mut filtered: Vec<usize> = self
.all
.iter()
.enumerate()
.filter(|(_, p)| query.matches_entry(p) && self.in_every_roster(&query, &p.name))
.map(|(idx, _)| idx)
.collect();
match self.sort {
SortKey::Dex => filtered.sort_unstable_by_key(|&idx| self.all[idx].id),
SortKey::Name => {
filtered.sort_unstable_by(|&a, &b| self.all[a].name.cmp(&self.all[b].name));
}
}
self.filtered = filtered;
self.parsed = query;
self.restore_highlight(anchor);
}
fn in_every_roster(&self, query: &Query, name: &str) -> bool {
query.rosters.iter().all(|term| {
self.rosters
.get(term)
.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.index_of(&name))
.and_then(|abs| self.filtered.iter().position(|&idx| idx == abs));
self.list_state.select(Some(restored.unwrap_or(0)));
}
pub fn awaiting_roster(&self) -> bool {
self.parsed
.rosters
.iter()
.any(|term| !self.rosters.contains_key(term))
}
pub fn cycle_sort(&mut self) {
self.sort = self.sort.next();
self.recompute();
}
pub 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.get(idx).map(|p| p.name.clone())
}
pub fn index_of(&self, name: &str) -> Option<usize> {
self.all.iter().position(|p| p.name == name)
}
pub fn position_of(&self, name: &str) -> Option<usize> {
self.filtered
.iter()
.position(|&idx| self.all[idx].name == name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{RosterKind, RosterTerm};
fn browsing(entries: &[(u32, &str)]) -> Browser {
let mut browser = Browser {
all: entries
.iter()
.map(|&(id, name)| PokemonEntry {
id,
name: name.to_string(),
})
.collect(),
..Browser::default()
};
browser.recompute();
browser
}
fn kanto_ghosts() -> Browser {
browsing(&[
(92, "gastly"),
(93, "haunter"),
(94, "gengar"),
(25, "pikachu"),
])
}
fn search(browser: &mut Browser, query: &str) {
browser.query = query.to_string();
browser.recompute();
}
fn visible(browser: &Browser) -> Vec<&str> {
browser
.filtered
.iter()
.map(|&idx| browser.all[idx].name.as_str())
.collect()
}
#[test]
fn every_sort_key_reads_back_out_of_its_code() {
for sort in [SortKey::Dex, SortKey::Name] {
assert_eq!(SortKey::from_code(sort.code()), Some(sort));
}
}
#[test]
fn an_unknown_sort_code_is_not_guessed_at() {
assert_eq!(SortKey::from_code("stat-total"), None);
assert_eq!(SortKey::from_code(""), None);
}
#[test]
fn a_narrowing_search_keeps_the_highlighted_species_under_the_cursor() {
let mut browser = kanto_ghosts();
browser.list_state.select(Some(3)); assert_eq!(browser.current_name().as_deref(), Some("gengar"));
search(&mut browser, "ga");
assert_eq!(visible(&browser), ["gastly", "gengar"]);
assert_eq!(browser.current_name().as_deref(), Some("gengar"));
}
#[test]
fn a_species_the_search_filters_out_drops_the_cursor_to_the_first_row() {
let mut browser = kanto_ghosts();
browser.list_state.select(Some(0)); search(&mut browser, "ga");
assert_eq!(browser.current_name().as_deref(), Some("gastly"));
search(&mut browser, "nothing matches this");
assert!(browser.filtered.is_empty());
assert_eq!(browser.list_state.selected(), None);
assert_eq!(browser.current_name(), None);
}
#[test]
fn sorting_reorders_the_list_without_moving_the_highlight() {
let mut browser = kanto_ghosts();
browser.list_state.select(Some(0)); assert_eq!(
visible(&browser),
["pikachu", "gastly", "haunter", "gengar"]
);
browser.cycle_sort();
assert_eq!(browser.sort, SortKey::Name);
assert_eq!(
visible(&browser),
["gastly", "gengar", "haunter", "pikachu"]
);
assert_eq!(
browser.current_name().as_deref(),
Some("pikachu"),
"the species stays under the cursor; only its row number changed"
);
browser.cycle_sort();
assert_eq!(browser.sort, SortKey::Dex);
assert_eq!(browser.current_name().as_deref(), Some("pikachu"));
}
#[test]
fn the_cursor_wraps_at_both_ends_and_an_empty_list_has_nowhere_to_go() {
let mut browser = kanto_ghosts();
browser.list_state.select(Some(0));
browser.move_selection(-1);
assert_eq!(
browser.list_state.selected(),
Some(3),
"off the top, round to the bottom"
);
browser.move_selection(1);
assert_eq!(browser.list_state.selected(), Some(0));
browser.move_selection(10);
assert_eq!(browser.list_state.selected(), Some(2));
let mut empty = browsing(&[]);
empty.move_selection(1);
assert_eq!(empty.list_state.selected(), None);
}
#[test]
fn a_filter_whose_roster_has_not_arrived_matches_nothing_and_says_so() {
let mut browser = kanto_ghosts();
search(&mut browser, "type:ghost");
assert!(browser.filtered.is_empty());
assert!(browser.awaiting_roster());
browser.rosters.insert(
RosterTerm {
kind: RosterKind::Type,
value: "ghost".to_string(),
},
["gastly", "haunter", "gengar"]
.iter()
.map(|n| n.to_string())
.collect(),
);
browser.recompute();
assert_eq!(visible(&browser), ["gastly", "haunter", "gengar"]);
assert!(!browser.awaiting_roster());
search(&mut browser, "type:nonsense");
browser.rosters.insert(
RosterTerm {
kind: RosterKind::Type,
value: "nonsense".to_string(),
},
HashSet::new(),
);
browser.recompute();
assert!(browser.filtered.is_empty());
assert!(!browser.awaiting_roster());
}
#[test]
fn a_name_is_found_in_the_master_list_and_in_the_filtered_one() {
let mut browser = kanto_ghosts();
search(&mut browser, "ga");
assert_eq!(browser.index_of("gengar"), Some(2));
assert_eq!(browser.position_of("gengar"), Some(1));
assert_eq!(browser.index_of("pikachu"), Some(3));
assert_eq!(browser.position_of("pikachu"), None);
assert_eq!(browser.index_of("missingno"), None);
}
}