use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Clear, List, ListItem, Paragraph, Wrap};
use ratatui::Frame;
use crate::app::{App, Focus};
use crate::browser::SortKey;
use crate::color;
use crate::compare;
use crate::i18n::{EvoStrings, Language, Strings};
use crate::models::{
egg_group_label, form_label, title_case, CatchEase, EvolutionTree, FieldData, LearnMethod,
LearnedMove, PokemonDetail, Sprite,
};
use crate::team::{self, AbilityImmunity};
use crate::theme;
use crate::typechart;
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const STAT_LABEL_WIDTH: usize = 9;
pub fn render(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let strings = app.language.strings();
frame.render_widget(
Block::default().style(Style::default().bg(theme::base())),
area,
);
let rows = Layout::vertical([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(area);
render_header(frame, app, &strings, rows[0]);
render_footer(frame, &strings, rows[2]);
let cols =
Layout::horizontal([Constraint::Percentage(32), Constraint::Percentage(68)]).split(rows[1]);
render_sidebar(frame, app, &strings, cols[0]);
let right =
Layout::vertical([Constraint::Percentage(58), Constraint::Percentage(42)]).split(cols[1]);
render_details(frame, app, &strings, right[0]);
render_evolution(frame, app, &strings, right[1]);
if app.matchups {
render_matchups(frame, app, &strings, area);
}
if app.ability_card {
render_abilities(frame, app, &strings, area);
}
if app.moves_card {
render_moves(frame, app, &strings, area);
}
if app.team_card {
render_team(frame, app, &strings, area);
}
if app.forms_card {
render_forms(frame, app, &strings, area);
}
if app.language_picker {
render_language_picker(frame, app, &strings, area);
}
if app.evo_card {
render_evolution_card(frame, app, &strings, area);
}
if app.compare_card {
render_compare(frame, app, &strings, area);
}
if app.help_card {
render_help(frame, &strings, area);
}
}
fn render_header(frame: &mut Frame, app: &App, s: &Strings, area: Rect) {
let cols = Layout::horizontal([Constraint::Min(0), Constraint::Length(12)]).split(area);
let title = Paragraph::new(Line::from(Span::styled(
s.app_title,
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
)));
frame.render_widget(title, cols[0]);
let tag = Paragraph::new(Line::from(vec![
Span::styled("◐ ", Style::default().fg(theme::peach())),
Span::styled(
app.language.tag(),
Style::default()
.fg(theme::peach())
.add_modifier(Modifier::BOLD),
),
]))
.alignment(Alignment::Right);
frame.render_widget(tag, cols[1]);
}
fn render_footer(frame: &mut Frame, s: &Strings, area: Rect) {
let footer = Paragraph::new(Line::from(Span::styled(
s.help,
Style::default().fg(theme::subtext()),
)))
.style(Style::default().bg(theme::surface()))
.alignment(Alignment::Center);
frame.render_widget(footer, area);
}
fn render_sidebar(frame: &mut Frame, app: &mut App, s: &Strings, area: Rect) {
let rows = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area);
let search_focused = app.focus == Focus::Search;
let search_block = panel_block(s.search_title, search_focused);
let cursor = if search_focused { "▏" } else { "" };
let query_line = if app.browser.query.is_empty() && !search_focused {
Line::from(Span::styled(
s.search_hint,
Style::default().fg(theme::overlay()),
))
} else {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(theme::sapphire())),
Span::styled(
app.browser.query.clone(),
Style::default().fg(theme::text()),
),
Span::styled(cursor, Style::default().fg(theme::mauve())),
])
};
frame.render_widget(Paragraph::new(query_line).block(search_block), rows[0]);
let list_focused = app.focus == Focus::List;
let sort_badge = match app.browser.sort {
SortKey::Dex => s.sort_dex,
SortKey::Name => s.sort_name,
};
let title = format!(
"{}({}) ⇅ {} ",
s.sidebar_title,
app.browser.filtered.len(),
sort_badge
);
let list_block = panel_block_owned(title, list_focused);
let inner = list_block.inner(rows[1]);
frame.render_widget(&list_block, rows[1]);
if app.list_loading {
render_centered_loading(frame, inner, s.loading_list, app.spinner);
return;
}
if app.awaiting_roster() {
render_centered_loading(frame, inner, s.loading_filter, app.spinner);
return;
}
if app.browser.filtered.is_empty() {
render_centered_text(frame, inner, s.no_results, theme::overlay());
return;
}
let items: Vec<ListItem> = app
.browser
.filtered
.iter()
.filter_map(|&idx| app.browser.all.get(idx))
.map(|p| {
let dex = match p.dex_number() {
Some(number) => format!("{number:>4} "),
None => " ".repeat(5),
};
let pin = if app.is_pinned(&p.name) { "◆" } else { " " };
let party = if app.is_in_team(&p.name) { "●" } else { " " };
ListItem::new(Line::from(vec![
Span::styled(pin, Style::default().fg(theme::teal())),
Span::styled(party, Style::default().fg(theme::green())),
Span::styled(dex, Style::default().fg(theme::overlay())),
Span::styled(title_case(&p.name), Style::default().fg(theme::text())),
]))
})
.collect();
let list = List::new(items)
.highlight_symbol("▶ ")
.highlight_style(color::highlight(theme::mauve()).add_modifier(Modifier::BOLD));
frame.render_stateful_widget(list, inner, &mut app.browser.list_state);
}
fn render_details(frame: &mut Frame, app: &App, s: &Strings, area: Rect) {
let block = panel_block(s.details_title, false);
let inner = block.inner(area);
frame.render_widget(block, area);
if app.detail_is_loading() {
render_centered_loading(frame, inner, s.loading, app.spinner);
return;
}
let Some(detail) = app.selected_detail() else {
match &app.error {
Some(err) => render_error(frame, inner, s, err),
None => render_centered_text(frame, inner, s.no_selection, theme::overlay()),
}
return;
};
let info = match app.selected_sprite() {
Some(sprite) if inner.width >= 46 && inner.height >= 6 => {
let sprite_w = sprite_col_width(inner);
let cols = Layout::horizontal([
Constraint::Length(sprite_w),
Constraint::Length(2),
Constraint::Min(0),
])
.split(inner);
render_sprite(frame, cols[0], sprite);
cols[2]
}
_ => inner,
};
let mut lines: Vec<Line> = Vec::new();
let mut title_spans = vec![
Span::styled(
title_case(&detail.name),
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" #{:04}", detail.dex_number),
Style::default().fg(theme::overlay()),
),
];
if app.sprite_variant.is_shiny() {
title_spans.push(Span::styled(
format!(" ✦ {}", s.shiny_label),
Style::default()
.fg(theme::yellow())
.add_modifier(Modifier::BOLD),
));
}
lines.push(Line::from(title_spans));
let lang_code = app.language.flavor_code();
if let Some(genus) = detail.genus_for(lang_code) {
lines.push(Line::from(Span::styled(
genus.to_string(),
Style::default()
.fg(theme::peach())
.add_modifier(Modifier::ITALIC),
)));
}
let mut badges: Vec<(&str, ratatui::style::Color)> = Vec::new();
if detail.is_legendary {
badges.push((s.legendary_label, theme::yellow()));
}
if detail.is_mythical {
badges.push((s.mythical_label, theme::pink()));
}
if detail.is_baby {
badges.push((s.baby_label, theme::teal()));
}
if !badges.is_empty() {
let mut spans = Vec::new();
for (label, color) in badges {
spans.push(Span::styled(
format!(" ✦ {label} "),
Style::default()
.fg(theme::base())
.bg(color)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
}
lines.push(Line::from(spans));
}
let mut type_spans = vec![Span::styled(
format!("{}: ", s.types_label),
Style::default().fg(theme::subtext()),
)];
type_spans.extend(type_chips(&detail.types));
lines.push(Line::from(type_spans));
if !detail.abilities.is_empty() {
let entries: Vec<String> = detail
.abilities
.iter()
.map(|ability| {
let name = ability_display_name(app, &ability.name);
match ability.is_hidden {
true => format!("{name} ({})", s.ability_hidden),
false => name,
}
})
.collect();
lines.extend(label_rows(
s.abilities_label,
&entries.join(" · "),
info.width as usize,
));
}
let forms = detail.other_forms();
if !forms.is_empty() {
let labels: Vec<String> = forms
.iter()
.map(|form| form_label(form, &detail.species))
.collect();
lines.extend(label_rows(
s.forms_label,
&labels.join(" · "),
info.width as usize,
));
}
lines.push(Line::from(vec![
Span::styled(
format!("{}: ", s.height_label),
Style::default().fg(theme::subtext()),
),
Span::styled(
format!("{:.1} m", detail.height as f32 / 10.0),
Style::default().fg(theme::text()),
),
Span::raw(" "),
Span::styled(
format!("{}: ", s.weight_label),
Style::default().fg(theme::subtext()),
),
Span::styled(
format!("{:.1} kg", detail.weight as f32 / 10.0),
Style::default().fg(theme::text()),
),
]));
lines.push(Line::raw(""));
let bar_width = (info.width as usize).saturating_sub(STAT_LABEL_WIDTH + 6);
for stat in &detail.stats {
lines.push(stat_line(
app.language.stat_label(stat.kind),
stat.base,
bar_width,
));
}
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled(
format!("{}: ", s.total_label),
Style::default().fg(theme::subtext()),
),
Span::styled(
detail.stat_total().to_string(),
Style::default()
.fg(theme::lavender())
.add_modifier(Modifier::BOLD),
),
]));
let facts = field_rows(&detail.field, s, info.width as usize);
if !facts.is_empty() && info.height as usize >= lines.len() + 1 + facts.len() {
lines.push(Line::raw(""));
lines.extend(facts);
}
let flavor = detail
.flavors
.get(lang_code)
.map(String::as_str)
.or_else(|| app.translation_for(&detail.name, lang_code))
.or_else(|| detail.flavors.get("en").map(String::as_str));
let flavor_rows = 4;
match flavor {
Some(flavor) if info.height as usize >= lines.len() + flavor_rows => {
let split =
Layout::vertical([Constraint::Min(0), Constraint::Length(flavor_rows as u16)])
.split(info);
frame.render_widget(Paragraph::new(lines), split[0]);
render_flavor_card(frame, split[1], flavor);
}
_ => frame.render_widget(Paragraph::new(lines), info),
}
}
fn label_rows(label: &str, text: &str, width: usize) -> Vec<Line<'static>> {
let label = format!("{label}: ");
let indent = " ".repeat(label.chars().count());
let budget = width.saturating_sub(label.chars().count());
wrap_plain(text, budget.max(8))
.into_iter()
.enumerate()
.map(|(row, text)| {
Line::from(vec![
Span::styled(
if row == 0 {
label.clone()
} else {
indent.clone()
},
Style::default().fg(theme::subtext()),
),
Span::styled(text, Style::default().fg(theme::text())),
])
})
.collect()
}
fn field_facts(field: &FieldData, s: &Strings) -> Vec<(String, String)> {
let mut facts = Vec::new();
if !field.egg_groups.is_empty() {
let groups: Vec<String> = field
.egg_groups
.iter()
.map(|g| egg_group_label(g))
.collect();
facts.push((s.egg_groups_label.to_string(), groups.join(" · ")));
}
let gender = match field.gender_split() {
Some((male, female)) => format!("♂ {} · ♀ {}", percent(male), percent(female)),
None => s.genderless.to_string(),
};
facts.push((String::new(), gender));
let ease = match field.catch_ease() {
CatchEase::Hard => s.catch_hard,
CatchEase::Average => s.catch_average,
CatchEase::Easy => s.catch_easy,
};
facts.push((
s.catch_rate_label.to_string(),
format!("{} ({ease})", field.capture_rate),
));
if let Some(rate) = &field.growth_rate {
facts.push((s.growth_label.to_string(), title_case(rate)));
}
if let Some(happiness) = field.base_happiness {
facts.push((s.happiness_label.to_string(), happiness.to_string()));
}
if let Some(habitat) = &field.habitat {
facts.push((s.habitat_label.to_string(), title_case(habitat)));
}
facts
}
fn percent(value: f32) -> String {
if value.fract() == 0.0 {
format!("{value:.0}%")
} else {
format!("{value:.1}%")
}
}
fn field_rows(field: &FieldData, s: &Strings, width: usize) -> Vec<Line<'static>> {
fact_rows(&field_facts(field, s), width)
}
fn fact_rows(facts: &[(String, String)], width: usize) -> Vec<Line<'static>> {
const GAP: &str = " ";
let mut rows = Vec::new();
let mut spans: Vec<Span<'static>> = Vec::new();
let mut used = 0;
for (label, value) in facts {
let label = match label.is_empty() {
true => String::new(),
false => format!("{label}: "),
};
let cell = label.chars().count() + value.chars().count();
if !spans.is_empty() && used + GAP.len() + cell > width {
rows.push(Line::from(std::mem::take(&mut spans)));
used = 0;
}
if !spans.is_empty() {
spans.push(Span::raw(GAP));
used += GAP.len();
}
spans.push(Span::styled(label, Style::default().fg(theme::subtext())));
spans.push(Span::styled(
value.clone(),
Style::default().fg(theme::text()),
));
used += cell;
}
if !spans.is_empty() {
rows.push(Line::from(spans));
}
rows
}
fn render_flavor_card(frame: &mut Frame, area: Rect, flavor: &str) {
let para = Paragraph::new(vec![Line::from(Span::styled(
format!("“{flavor}”"),
Style::default()
.fg(theme::subtext())
.add_modifier(Modifier::ITALIC),
))])
.wrap(Wrap { trim: true });
frame.render_widget(para, area);
}
const MAX_SPRITE_COLS: u16 = 40;
fn sprite_col_width(inner: Rect) -> u16 {
let by_width = inner.width * 2 / 5;
let by_height = inner.height.saturating_mul(2);
let w = by_width.min(by_height).min(MAX_SPRITE_COLS);
(w & !1).max(2) }
fn render_sprite(frame: &mut Frame, area: Rect, sprite: &Sprite) {
render_sprite_capped(frame, area, sprite, MAX_SPRITE_COLS);
}
fn render_sprite_capped(frame: &mut Frame, area: Rect, sprite: &Sprite, max_cols: u16) {
if area.width < 2 || area.height < 1 || sprite.width() == 0 || sprite.height() == 0 {
return;
}
let (bx0, by0, bx1, by1) = sprite.content_bounds();
let bw = (bx1 - bx0 + 1) as f32;
let bh = (by1 - by0 + 1) as f32;
let max_w = area.width.min(max_cols) as f32;
let max_h_px = (area.height as f32) * 2.0;
let scale = (max_w / bw).min(max_h_px / bh);
let cols = (((bw * scale) as u16).max(2)) & !1; let rows = ((bh * scale) as u16).div_ceil(2).max(1);
let bw = bw as u32;
let bh = bh as u32;
let cols_u = cols as u32;
let sub_rows = 2 * rows as u32;
let span_x = |cx: u32| {
(
bx0 + cx * bw / cols_u,
bx0 + ((cx + 1) * bw / cols_u).saturating_sub(1),
)
};
let span_y = |py: u32| {
(
by0 + py * bh / sub_rows,
by0 + ((py + 1) * bh / sub_rows).saturating_sub(1),
)
};
let mut lines: Vec<Line> = Vec::with_capacity(rows as usize);
for cy in 0..rows {
let (ty0, ty1) = span_y(2 * cy as u32);
let (by_0, by_1) = span_y(2 * cy as u32 + 1);
let mut spans: Vec<Span> = Vec::with_capacity(cols as usize);
for cx in 0..cols {
let (sx0, sx1) = span_x(cx as u32);
let top = pixel_color(sprite.box_average(sx0, ty0, sx1, ty1));
let bottom = pixel_color(sprite.box_average(sx0, by_0, sx1, by_1));
spans.push(Span::styled("▀", Style::default().fg(top).bg(bottom)));
}
lines.push(Line::from(spans));
}
let target = Rect {
x: area.x + (area.width.saturating_sub(cols)) / 2,
y: area.y + (area.height.saturating_sub(rows)) / 2,
width: cols,
height: rows,
};
frame.render_widget(Paragraph::new(lines), target);
}
fn pixel_color(rgba: [u8; 4]) -> Color {
let a = rgba[3] as u16;
if a == 0 {
return theme::base();
}
let (br, bg, bb) = theme::base_rgb();
let mix = |fg: u8, bg: u8| ((fg as u16 * a + bg as u16 * (255 - a)) / 255) as u8;
theme::ink((mix(rgba[0], br), mix(rgba[1], bg), mix(rgba[2], bb)))
}
fn render_evolution(frame: &mut Frame, app: &App, s: &Strings, area: Rect) {
let focused = app.focus == Focus::Evolution;
let block = if app.sprite_variant.is_shiny() {
panel_block_owned(
format!("{}✦ {} ", s.evolution_title, s.shiny_label),
focused,
)
} else {
panel_block(s.evolution_title, focused)
};
let inner = block.inner(area);
frame.render_widget(block, area);
if app.detail_is_loading() {
render_centered_loading(frame, inner, s.loading, app.spinner);
return;
}
let Some(tree) = app.selected_evolution() else {
if app.selected_detail().is_some() {
render_centered_text(frame, inner, s.no_evolution, theme::overlay());
} else {
render_centered_text(frame, inner, s.no_selection, theme::overlay());
}
return;
};
let current = app
.selected_detail()
.map(|d| d.species.as_str())
.or(app.selected_name.as_deref());
let cursor_name = if focused {
app.chain_names().get(app.evo_cursor).cloned()
} else {
None
};
let cursor = cursor_name.as_deref();
let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(inner);
draw_chain(frame, app, s, tree, current, cursor, rows[0]);
let fallback = if focused {
s.evo_nav_hint
} else {
s.expand_hint
};
frame.render_widget(
Paragraph::new(chain_hint(tree, cursor, s, fallback)).alignment(Alignment::Center),
rows[1],
);
}
fn render_evolution_card(frame: &mut Frame, app: &App, s: &Strings, full: Rect) {
let Some(tree) = app.selected_evolution() else {
return; };
if full.width < MIN_CARD_W + 2 || full.height < MIN_CARD_H + 3 {
return; }
frame.render_widget(Clear, full);
let title = if app.sprite_variant.is_shiny() {
format!("{}✦ {} ", s.evolution_title, s.shiny_label)
} else {
s.evolution_title.to_string()
};
let block = Block::bordered()
.border_type(BorderType::Double)
.border_style(Style::default().fg(theme::mauve()))
.title(Span::styled(
title,
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme::base()));
let inner = block.inner(full);
frame.render_widget(block, full);
let current = app
.selected_detail()
.map(|d| d.species.as_str())
.or(app.selected_name.as_deref());
let cursor_name = app.chain_names().get(app.evo_cursor).cloned();
let cursor = cursor_name.as_deref();
let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(inner);
draw_chain(frame, app, s, tree, current, cursor, rows[0]);
frame.render_widget(
Paragraph::new(chain_hint(tree, cursor, s, s.evo_card_hint)).alignment(Alignment::Center),
rows[1],
);
}
fn draw_chain(
frame: &mut Frame,
app: &App,
s: &Strings,
tree: &EvolutionTree,
current: Option<&str>,
cursor: Option<&str>,
canvas: Rect,
) {
let depth = tree.depth() as u16;
let leaves = tree.leaf_count() as u16;
match card_grid(canvas, depth, leaves) {
Some((col_w, lane_h)) => {
let canvas = centered_fixed(col_w * depth, lane_h * leaves, canvas);
let mut lane = 0u16;
place_node(
frame, app, s, tree, current, cursor, canvas, col_w, lane_h, 0, &mut lane,
);
}
None => {
let lines = evolution_lines(tree, cursor.or(current), &s.evo, canvas.width);
frame.render_widget(Paragraph::new(lines), canvas);
}
}
}
fn card_grid(canvas: Rect, depth: u16, leaves: u16) -> Option<(u16, u16)> {
let col_w = canvas.width.checked_div(depth)?.min(MAX_CARD_W + EVO_GAP);
let lane_h = canvas.height.checked_div(leaves)?;
(col_w >= MIN_CARD_W && lane_h >= MIN_CARD_H).then_some((col_w, lane_h))
}
fn chain_hint(
tree: &EvolutionTree,
cursor: Option<&str>,
s: &Strings,
fallback: &'static str,
) -> Line<'static> {
let requirement = cursor
.and_then(|name| tree.find(name))
.and_then(|node| node.condition.as_ref())
.map(|condition| s.evo.summary(condition))
.filter(|text| !text.is_empty());
match requirement {
Some(text) => Line::from(vec![
Span::styled("✦ ", Style::default().fg(theme::peach())),
Span::styled(text, Style::default().fg(theme::lavender())),
]),
None => Line::from(Span::styled(
fallback,
Style::default().fg(theme::overlay()),
)),
}
}
fn panel_block(title: &'static str, focused: bool) -> Block<'static> {
panel_block_owned(title.to_string(), focused)
}
fn panel_block_owned(title: String, focused: bool) -> Block<'static> {
let (border, text, border_type) = if focused {
(theme::mauve(), theme::mauve(), BorderType::Double)
} else {
(theme::overlay(), theme::subtext(), BorderType::Plain)
};
Block::bordered()
.border_type(border_type)
.border_style(Style::default().fg(border))
.title(Span::styled(
title,
Style::default().fg(text).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme::base()))
}
fn stat_line(label: &str, base: u16, bar_width: usize) -> Line<'static> {
let filled = if bar_width == 0 {
0
} else {
((base as usize * bar_width) / 255).min(bar_width)
};
Line::from(vec![
Span::styled(
format!("{label:<STAT_LABEL_WIDTH$}"),
Style::default().fg(theme::subtext()),
),
Span::styled(format!("{base:>3} "), Style::default().fg(theme::text())),
Span::styled(
"█".repeat(filled),
Style::default().fg(theme::stat_color(base)),
),
Span::styled(
"░".repeat(bar_width - filled),
Style::default().fg(theme::surface()),
),
])
}
fn render_error(frame: &mut Frame, inner: Rect, s: &Strings, err: &str) {
let para = Paragraph::new(vec![
Line::from(Span::styled(
format!("⚠ {}", s.error_prefix),
Style::default()
.fg(theme::red())
.add_modifier(Modifier::BOLD),
)),
Line::raw(""),
Line::from(Span::styled(
err.to_string(),
Style::default().fg(theme::subtext()),
)),
])
.wrap(ratatui::widgets::Wrap { trim: true });
frame.render_widget(para, inner);
}
fn render_centered_text(frame: &mut Frame, inner: Rect, text: &str, color: ratatui::style::Color) {
if inner.height == 0 {
return;
}
let row = Rect {
x: inner.x,
y: inner.y + inner.height / 2,
width: inner.width,
height: 1,
};
let para = Paragraph::new(Line::from(Span::styled(
text.to_string(),
Style::default().fg(color),
)))
.alignment(Alignment::Center);
frame.render_widget(para, row);
}
fn render_centered_loading(frame: &mut Frame, inner: Rect, label: &str, spinner: usize) {
if inner.height == 0 {
return;
}
let frame_char = SPINNER[spinner % SPINNER.len()];
let row = Rect {
x: inner.x,
y: inner.y + inner.height / 2,
width: inner.width,
height: 1,
};
let para = Paragraph::new(Line::from(vec![
Span::styled(
format!("{frame_char} "),
Style::default().fg(theme::mauve()),
),
Span::styled(format!("{label}…"), Style::default().fg(theme::subtext())),
]))
.alignment(Alignment::Center);
frame.render_widget(para, row);
}
fn evolution_lines(
tree: &EvolutionTree,
highlight: Option<&str>,
evo: &EvoStrings,
width: u16,
) -> Vec<Line<'static>> {
node_block(tree, highlight, evo, requirement_budget(width))
.into_iter()
.map(Line::from)
.collect()
}
fn requirement_budget(width: u16) -> usize {
(width as usize).saturating_sub(28).clamp(12, 40)
}
fn node_block(
node: &EvolutionTree,
highlight: Option<&str>,
evo: &EvoStrings,
budget: usize,
) -> Vec<Vec<Span<'static>>> {
let mut run: Vec<&EvolutionTree> = vec![node];
let mut cur = node;
while cur.children.len() == 1 {
cur = &cur.children[0];
run.push(cur);
}
let mut first: Vec<Span<'static>> = Vec::new();
let mut width = 0usize;
let mut indent_width = 0usize;
for (i, n) in run.iter().enumerate() {
if i > 0 {
first.push(Span::styled(" ──▶ ", Style::default().fg(theme::overlay())));
width += 5; }
if i + 1 == run.len() {
indent_width = width; }
first.push(name_span(&n.name, highlight));
width += title_case(&n.name).chars().count();
if let Some(label) = condition_label(n, evo, budget) {
width += label.chars().count();
first.push(Span::styled(label, Style::default().fg(theme::overlay())));
}
}
let mut lines = vec![first];
if cur.children.len() > 1 {
let indent = " ".repeat(indent_width);
let count = cur.children.len();
for (i, child) in cur.children.iter().enumerate() {
let is_last = i == count - 1;
for (j, child_row) in node_block(child, highlight, evo, budget)
.into_iter()
.enumerate()
{
let connector = if j == 0 {
if is_last {
"└── "
} else {
"├── "
}
} else if is_last {
" "
} else {
"│ "
};
let mut row = vec![Span::styled(
format!("{indent}{connector}"),
Style::default().fg(theme::overlay()),
)];
row.extend(child_row);
lines.push(row);
}
}
}
lines
}
fn condition_label(node: &EvolutionTree, evo: &EvoStrings, budget: usize) -> Option<String> {
let text = node.condition.as_ref().and_then(|c| evo.short(c))?;
Some(format!(" ({})", truncate(&text, budget)))
}
fn truncate(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
if max <= 1 {
return "…".to_string();
}
text.chars()
.take(max - 1)
.chain(std::iter::once('…'))
.collect()
}
fn name_span(raw_name: &str, highlight: Option<&str>) -> Span<'static> {
let style = if highlight == Some(raw_name) {
Style::default()
.fg(theme::yellow())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::green())
};
Span::styled(title_case(raw_name), style)
}
const MIN_CARD_W: u16 = 10;
const MIN_CARD_H: u16 = 4;
const EVO_GAP: u16 = 5;
const MAX_CARD_W: u16 = 30;
#[allow(clippy::too_many_arguments)]
fn place_node(
frame: &mut Frame,
app: &App,
s: &Strings,
node: &EvolutionTree,
current: Option<&str>,
cursor: Option<&str>,
canvas: Rect,
col_w: u16,
lane_h: u16,
depth_idx: u16,
lane: &mut u16,
) -> u16 {
let x = canvas.x + depth_idx * col_w;
let card_w = col_w.saturating_sub(EVO_GAP);
if node.children.is_empty() {
let top = canvas.y + *lane * lane_h;
*lane += 1;
draw_card(frame, app, s, node, current, cursor, x, top, card_w, lane_h);
return top + lane_h / 2;
}
let centers: Vec<u16> = node
.children
.iter()
.map(|child| {
place_node(
frame,
app,
s,
child,
current,
cursor,
canvas,
col_w,
lane_h,
depth_idx + 1,
lane,
)
})
.collect();
let first = *centers.first().unwrap();
let last = *centers.last().unwrap();
let cy = (first + last) / 2;
let top = cy.saturating_sub(lane_h / 2);
draw_card(frame, app, s, node, current, cursor, x, top, card_w, lane_h);
let child_x = canvas.x + (depth_idx + 1) * col_w;
draw_connectors(frame, x + card_w, child_x, cy, ¢ers);
cy
}
#[allow(clippy::too_many_arguments)]
fn draw_card(
frame: &mut Frame,
app: &App,
s: &Strings,
node: &EvolutionTree,
current: Option<&str>,
cursor: Option<&str>,
x: u16,
top: u16,
w: u16,
h: u16,
) {
if w == 0 || h == 0 {
return;
}
let condition = node.condition.as_ref().and_then(|c| s.evo.short(c));
let stacked = condition.is_some() && h > MIN_CARD_H;
let text_rows = if stacked { 2 } else { 1 };
let sprite_area = Rect {
x,
y: top,
width: w,
height: h.saturating_sub(text_rows),
};
match app.sprite_for(&node.name) {
Some(sprite) => render_sprite_capped(frame, sprite_area, sprite, w),
None => {
let placeholder = if app.sprite_is_loading(&node.name) {
s.sprite_loading
} else {
"…"
};
render_centered_text(frame, sprite_area, placeholder, theme::overlay());
}
}
let is_cursor = cursor == Some(node.name.as_str());
let is_current = current == Some(node.name.as_str());
let style = if is_cursor {
color::highlight(theme::yellow()).add_modifier(Modifier::BOLD)
} else if is_current {
Style::default()
.fg(theme::yellow())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::green())
};
let label = title_case(&node.name);
let mut name_spans = vec![Span::styled(label.clone(), style)];
if let (Some(text), false) = (&condition, stacked) {
let free = (w as usize).saturating_sub(label.chars().count());
if free >= 6 {
name_spans.push(Span::styled(
truncate(&format!(" · {text}"), free),
Style::default().fg(theme::peach()),
));
}
}
let name_y = top + h.saturating_sub(text_rows);
let name = Paragraph::new(Line::from(name_spans)).alignment(Alignment::Center);
frame.render_widget(
name,
Rect {
x,
y: name_y,
width: w,
height: 1,
},
);
if let (Some(text), true) = (&condition, stacked) {
let requirement = Paragraph::new(Line::from(Span::styled(
truncate(text, w as usize),
Style::default().fg(theme::peach()),
)))
.alignment(Alignment::Center);
frame.render_widget(
requirement,
Rect {
x,
y: name_y + 1,
width: w,
height: 1,
},
);
}
}
fn draw_connectors(frame: &mut Frame, x_from: u16, x_to: u16, parent_cy: u16, centers: &[u16]) {
let color = theme::overlay();
if x_to <= x_from {
return;
}
if centers.len() == 1 {
let cy = centers[0];
for x in x_from..x_to.saturating_sub(1) {
put_cell(frame, x, cy, "─", color);
}
put_cell(frame, x_to.saturating_sub(1), cy, "▶", theme::mauve());
return;
}
let trunk_x = x_from + (x_to - x_from) / 2;
let min_c = *centers.iter().min().unwrap();
let max_c = *centers.iter().max().unwrap();
for x in x_from..trunk_x {
put_cell(frame, x, parent_cy, "─", color);
}
for y in min_c..=max_c {
put_cell(frame, trunk_x, y, "│", color);
}
let junction = if centers.contains(&parent_cy) {
"┼"
} else {
"┤"
};
put_cell(frame, trunk_x, parent_cy, junction, color);
for &cy in centers {
let corner = if cy == min_c {
"┌"
} else if cy == max_c {
"└"
} else {
"├"
};
if cy != parent_cy {
put_cell(frame, trunk_x, cy, corner, color);
}
for x in (trunk_x + 1)..x_to.saturating_sub(1) {
put_cell(frame, x, cy, "─", color);
}
put_cell(frame, x_to.saturating_sub(1), cy, "▶", theme::mauve());
}
}
fn put_cell(frame: &mut Frame, x: u16, y: u16, symbol: &str, color: Color) {
let area = frame.area();
if x < area.x || y < area.y || x >= area.right() || y >= area.bottom() {
return;
}
if let Some(cell) = frame.buffer_mut().cell_mut(Position::new(x, y)) {
cell.set_symbol(symbol).set_fg(color);
}
}
const MATCHUP_CARD_W: u16 = 48;
const TEAM_CARD_W: u16 = 56;
const ABILITY_CARD_W: u16 = 60;
const MOVES_CARD_W: u16 = 66;
const MOVE_NUM_W: usize = 5;
const COMPARE_CARD_W: u16 = 72;
const COMPARE_VAL_W: usize = 4;
const COMPARE_MARGIN_W: usize = 6;
const MATCHUP_LABEL_W: usize = 5;
fn render_matchups(frame: &mut Frame, app: &App, s: &Strings, full: Rect) {
let Some(detail) = app.selected_detail() else {
return; };
let width = MATCHUP_CARD_W.min(full.width);
let text_w = width.saturating_sub(2) as usize; if text_w < 16 || full.height < 8 {
return; }
let mut lines: Vec<Line> = Vec::new();
let mut head = vec![Span::styled(
format!(" {} ", title_case(&detail.name)),
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
)];
head.extend(type_chips(&detail.types));
lines.push(Line::from(head));
lines.push(Line::raw(""));
let immunities = team::ability_immunities(detail);
let certain: Vec<&str> = immunities
.iter()
.filter(|immunity| immunity.certain)
.map(|immunity| immunity.immune_to)
.collect();
lines.push(section_heading(s.matchups_defense));
for group in typechart::defensive_groups(&detail.types, &certain) {
lines.extend(chip_rows(group.label, &group.types, text_w));
}
if !immunities.is_empty() {
lines.push(Line::raw(""));
lines.push(section_heading(s.immune_by_ability));
for immunity in &immunities {
lines.push(ability_immunity_row(app, s, immunity, " "));
}
}
lines.push(Line::raw(""));
lines.push(section_heading(s.matchups_offense));
let coverage = typechart::offensive_coverage(&detail.types);
if coverage.is_empty() {
lines.push(Line::from(Span::styled(
format!(" {}", s.matchups_none),
Style::default().fg(theme::overlay()),
)));
} else {
lines.extend(chip_rows("", &coverage, text_w));
}
let height = (lines.len() as u16 + 3).min(full.height);
let area = centered_fixed(width, height, full);
frame.render_widget(Clear, area);
let block = Block::bordered()
.border_type(BorderType::Double)
.border_style(Style::default().fg(theme::mauve()))
.title(Span::styled(
s.matchups_title,
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme::surface()));
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(inner);
frame.render_widget(Paragraph::new(lines), rows[0]);
let hint = Paragraph::new(Line::from(Span::styled(
s.close_hint,
Style::default().fg(theme::overlay()),
)))
.alignment(Alignment::Center);
frame.render_widget(hint, rows[1]);
}
fn type_chips(types: &[String]) -> Vec<Span<'static>> {
let mut spans = Vec::with_capacity(types.len() * 2);
for ty in types {
spans.push(Span::styled(
format!(" {} ", title_case(ty)),
Style::default().fg(theme::base()).bg(theme::type_color(ty)),
));
spans.push(Span::raw(" "));
}
spans
}
fn section_heading(text: &str) -> Line<'static> {
Line::from(Span::styled(
format!(" {text}"),
Style::default()
.fg(theme::peach())
.add_modifier(Modifier::BOLD),
))
}
fn chip_rows(label: &str, types: &[&str], max_width: usize) -> Vec<Line<'static>> {
let indent = " ".repeat(MATCHUP_LABEL_W);
let mut rows: Vec<Line> = Vec::new();
let mut spans: Vec<Span> = vec![Span::styled(
format!(" {label:<pad$} ", pad = MATCHUP_LABEL_W - 2),
Style::default()
.fg(theme::subtext())
.add_modifier(Modifier::BOLD),
)];
let mut used = MATCHUP_LABEL_W;
for ty in types {
let chip = format!(" {} ", title_case(ty));
let chip_w = chip.chars().count() + 1; if used + chip_w > max_width && used > MATCHUP_LABEL_W {
rows.push(Line::from(std::mem::take(&mut spans)));
spans.push(Span::raw(indent.clone()));
used = MATCHUP_LABEL_W;
}
spans.push(Span::styled(
chip,
Style::default().fg(theme::base()).bg(theme::type_color(ty)),
));
spans.push(Span::raw(" "));
used += chip_w;
}
rows.push(Line::from(spans));
rows
}
const HELP_CARD_W: u16 = 86;
fn render_help(frame: &mut Frame, s: &Strings, full: Rect) {
let h = &s.help_card;
let left: Vec<(&str, &str)> = vec![
("", h.ctx_list),
("↑ ↓ · j k", h.act_move),
("PgUp PgDn", h.act_jump10),
("Enter", h.act_load),
("/ · Tab", h.act_search),
("E", h.act_evolutions),
("F", h.act_chain_expand),
("T", h.act_types),
("C", h.act_compare),
("A", h.act_abilities),
("M", h.act_moves),
("V", h.act_forms),
("X", h.act_shiny),
("R", h.act_random),
("Space", h.act_party_toggle),
("P", h.act_party_card),
("S", h.act_sort),
("L", h.act_language),
("?", h.act_help),
("Q", h.act_quit),
];
let right: Vec<(&str, &str)> = vec![
("", h.ctx_search),
("Enter", h.act_load_back),
("Esc · Tab", h.act_back),
("type:water", h.act_by_type),
("ability:levitate", h.act_by_ability),
("egg:dragon", h.act_by_egg),
("gen:1", h.act_by_generation),
("", ""),
("", h.ctx_evolution),
("← → ↑ ↓ · h j k l", h.act_chain_move),
("Enter", h.act_chain_jump),
("F", h.act_chain_expand),
("X", h.act_shiny),
("Esc · Tab", h.act_back),
("", ""),
("", h.ctx_party),
("↑ ↓ · j k", h.act_move),
("C", h.act_compare),
("", ""),
("", h.ctx_forms),
("↑ ↓ · j k", h.act_move),
("Enter", h.act_form_jump),
("", ""),
("", h.ctx_cards),
("Esc", h.act_close),
("Ctrl-C", h.act_quit),
];
let rows = left.len().max(right.len()) as u16;
let width = HELP_CARD_W.min(full.width);
let height = (rows + 4).min(full.height);
if width < 40 || height < 8 {
return; }
let area = centered_fixed(width, height, full);
frame.render_widget(Clear, area);
let block = Block::bordered()
.border_type(BorderType::Double)
.border_style(Style::default().fg(theme::mauve()))
.title(Span::styled(
h.title,
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme::surface()));
let inner = block.inner(area);
frame.render_widget(block, area);
let body = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(inner);
let cols =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(body[0]);
frame.render_widget(Paragraph::new(help_lines(&left)), cols[0]);
frame.render_widget(Paragraph::new(help_lines(&right)), cols[1]);
let hint = Paragraph::new(Line::from(Span::styled(
h.close_hint,
Style::default().fg(theme::overlay()),
)))
.alignment(Alignment::Center);
frame.render_widget(hint, body[1]);
}
fn help_lines(rows: &[(&str, &str)]) -> Vec<Line<'static>> {
let key_w = rows
.iter()
.map(|(keys, _)| keys.chars().count())
.max()
.unwrap_or(0)
+ 2;
rows.iter()
.map(|(keys, action)| {
if keys.is_empty() {
return match action.is_empty() {
true => Line::raw(""),
false => section_heading(action),
};
}
Line::from(vec![
Span::styled(
format!(" {keys:<key_w$}"),
Style::default()
.fg(theme::teal())
.add_modifier(Modifier::BOLD),
),
Span::styled((*action).to_string(), Style::default().fg(theme::subtext())),
])
})
.collect()
}
fn render_moves(frame: &mut Frame, app: &App, s: &Strings, full: Rect) {
let Some(detail) = app.selected_detail() else {
return;
};
let learnset = detail.moves.as_slice();
let width = MOVES_CARD_W.min(full.width);
let table_w = width.saturating_sub(2) as usize;
let text_w = width.saturating_sub(4) as usize;
if text_w < 40 || full.height < 12 {
return; }
let block = Block::bordered()
.border_type(BorderType::Double)
.border_style(Style::default().fg(theme::mauve()))
.title(Span::styled(
s.moves_title,
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme::surface()));
let height = full
.height
.saturating_sub(4)
.min(learnset.len() as u16 + 10);
let area = centered_fixed(width, height, full);
frame.render_widget(Clear, area);
let inner = block.inner(area);
frame.render_widget(block, area);
if learnset.is_empty() {
render_centered_text(frame, inner, s.moves_empty, theme::overlay());
return;
}
let rows = Layout::vertical([
Constraint::Length(2), Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
.split(inner);
let mut heading = vec![Span::styled(
format!(" {}", title_case(&detail.name)),
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
)];
if let Some(games) = &detail.learnset_games {
heading.push(Span::styled(
format!(" · {}", title_case(games)),
Style::default().fg(theme::overlay()),
));
}
frame.render_widget(
Paragraph::new(vec![Line::from(heading), {
let (left, middle, right) = move_columns(
s.col_learn,
s.col_move,
s.col_type,
s.col_category,
s.col_power,
s.col_accuracy,
s.col_pp,
table_w,
);
Line::from(Span::styled(
format!("{left}{middle}{right}"),
Style::default().fg(theme::overlay()),
))
}]),
rows[0],
);
let window = rows[1].height as usize;
let first = app
.move_cursor
.saturating_sub(window / 2)
.min(learnset.len().saturating_sub(window));
let lines: Vec<Line> = learnset
.iter()
.enumerate()
.skip(first)
.take(window)
.map(|(idx, learned)| move_row(app, s, learned, idx == app.move_cursor, table_w))
.collect();
frame.render_widget(Paragraph::new(lines), rows[1]);
frame.render_widget(Paragraph::new(move_description(app, s, text_w)), rows[2]);
let hint = Paragraph::new(Line::from(Span::styled(
s.moves_close_hint,
Style::default().fg(theme::overlay()),
)))
.alignment(Alignment::Center);
frame.render_widget(hint, rows[3]);
}
#[allow(clippy::too_many_arguments)]
fn move_columns(
learn: &str,
name: &str,
type_name: &str,
category: &str,
power: &str,
accuracy: &str,
pp: &str,
width: usize,
) -> (String, String, String) {
let name_w = width.saturating_sub(12 + 4 * MOVE_NUM_W + 8).max(8);
(
format!(" {learn:>7} {name:<name_w$} "),
format!("{type_name:<9}"),
format!(
" {category:<MOVE_NUM_W$}{power:>MOVE_NUM_W$}{accuracy:>MOVE_NUM_W$}{pp:>MOVE_NUM_W$}"
),
)
}
fn move_row<'a>(
app: &'a App,
s: &Strings,
learned: &'a LearnedMove,
highlighted: bool,
width: usize,
) -> Line<'a> {
let code = app.language.flavor_code();
let info = app.moves.get(&learned.name);
let learn = match learned.method {
LearnMethod::LevelUp if learned.level == 0 => "—".to_string(),
LearnMethod::LevelUp => learned.level.to_string(),
LearnMethod::Machine => s.learn_machine.to_string(),
LearnMethod::Egg => s.learn_egg.to_string(),
LearnMethod::Tutor => s.learn_tutor.to_string(),
};
let name = match info {
Some(info) => info.name_for(code),
None => title_case(&learned.name),
};
let (type_name, category, power, accuracy, pp) = match info {
Some(info) => (
info.type_name.to_uppercase(),
damage_class_label(s, &info.damage_class).to_string(),
info.power
.map_or_else(|| "—".to_string(), |p| p.to_string()),
info.accuracy
.map_or_else(|| "—".to_string(), |a| a.to_string()),
info.pp.map_or_else(|| "—".to_string(), |p| p.to_string()),
),
None => (
String::new(),
String::new(),
String::new(),
String::new(),
String::new(),
),
};
let (left, middle, right) = move_columns(
&learn, &name, &type_name, &category, &power, &accuracy, &pp, width,
);
if highlighted {
let style = color::highlight(theme::mauve()).add_modifier(Modifier::BOLD);
return Line::from(Span::styled(format!("{left}{middle}{right}"), style));
}
let plain = Style::default().fg(theme::text());
Line::from(vec![
Span::styled(left, plain),
Span::styled(
middle,
Style::default().fg(theme::type_color(&learned_type(app, learned))),
),
Span::styled(right, Style::default().fg(theme::subtext())),
])
}
fn learned_type(app: &App, learned: &LearnedMove) -> String {
app.moves
.get(&learned.name)
.map(|info| info.type_name.clone())
.unwrap_or_default()
}
fn move_description<'a>(app: &App, s: &Strings, width: usize) -> Vec<Line<'a>> {
let code = app.language.flavor_code();
let text = app
.highlighted_move()
.and_then(|learned| app.moves.get(&learned.name))
.and_then(|info| info.flavor_for(code));
match text {
Some(text) => wrap_plain(text, width)
.into_iter()
.take(3)
.map(|row| {
Line::from(Span::styled(
format!(" {row}"),
Style::default().fg(theme::subtext()),
))
})
.collect(),
None => vec![Line::from(Span::styled(
format!(" {}…", s.loading),
Style::default().fg(theme::overlay()),
))],
}
}
fn damage_class_label<'a>(s: &Strings, class: &'a str) -> &'a str
where
'static: 'a,
{
match class {
"physical" => s.class_physical,
"special" => s.class_special,
"status" => s.class_status,
other => other,
}
}
fn render_abilities(frame: &mut Frame, app: &App, s: &Strings, full: Rect) {
let Some(detail) = app.selected_detail() else {
return;
};
let width = ABILITY_CARD_W.min(full.width);
let text_w = width.saturating_sub(4) as usize;
if text_w < 16 || full.height < 8 {
return; }
let mut lines: Vec<Line> = Vec::new();
let code = app.language.flavor_code();
lines.push(Line::from(Span::styled(
format!(" {}", title_case(&detail.name)),
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
)));
for ability in &detail.abilities {
lines.push(Line::raw(""));
let mut head = vec![Span::styled(
format!(" {}", ability_display_name(app, &ability.name)),
Style::default()
.fg(theme::peach())
.add_modifier(Modifier::BOLD),
)];
if ability.is_hidden {
head.push(Span::styled(
format!(" ({})", s.ability_hidden),
Style::default().fg(theme::overlay()),
));
}
lines.push(Line::from(head));
match app
.abilities
.get(&ability.name)
.and_then(|info| info.flavor_for(code))
{
Some(text) => {
for row in wrap_plain(text, text_w) {
lines.push(Line::from(Span::styled(
format!(" {row}"),
Style::default().fg(theme::subtext()),
)));
}
}
None => lines.push(Line::from(Span::styled(
format!(" {}…", s.loading),
Style::default().fg(theme::overlay()),
))),
}
}
let height = (lines.len() as u16 + 3).min(full.height);
let area = centered_fixed(width, height, full);
frame.render_widget(Clear, area);
let block = Block::bordered()
.border_type(BorderType::Double)
.border_style(Style::default().fg(theme::mauve()))
.title(Span::styled(
s.abilities_title,
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme::surface()));
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(inner);
frame.render_widget(Paragraph::new(lines), rows[0]);
let hint = Paragraph::new(Line::from(Span::styled(
s.ability_close_hint,
Style::default().fg(theme::overlay()),
)))
.alignment(Alignment::Center);
frame.render_widget(hint, rows[1]);
}
fn ability_immunity_row(
app: &App,
s: &Strings,
immunity: &AbilityImmunity,
lead: &str,
) -> Line<'static> {
let mut row = vec![
Span::styled(lead.to_string(), Style::default().fg(theme::text())),
Span::styled(
ability_display_name(app, &immunity.ability),
Style::default().fg(theme::subtext()),
),
Span::styled(" → ", Style::default().fg(theme::overlay())),
Span::styled(
format!(" {} ", title_case(immunity.immune_to)),
Style::default()
.fg(theme::base())
.bg(theme::type_color(immunity.immune_to)),
),
];
if !immunity.certain {
row.push(Span::styled(
format!(" ({})", s.immunity_maybe),
Style::default().fg(theme::overlay()),
));
}
Line::from(row)
}
fn ability_display_name(app: &App, slug: &str) -> String {
match app.abilities.get(slug) {
Some(info) => info.name_for(app.language.flavor_code()),
None => title_case(slug),
}
}
fn wrap_plain(text: &str, width: usize) -> Vec<String> {
let mut rows = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
if !current.is_empty() && current.chars().count() + 1 + word.chars().count() > width {
rows.push(std::mem::take(&mut current));
}
if !current.is_empty() {
current.push(' ');
}
current.push_str(word);
}
if !current.is_empty() {
rows.push(current);
}
rows
}
fn render_team(frame: &mut Frame, app: &App, s: &Strings, full: Rect) {
let width = TEAM_CARD_W.min(full.width);
let text_w = width.saturating_sub(2) as usize;
if text_w < 16 || full.height < 8 {
return; }
let loaded = app.team_details();
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(Span::styled(
format!(" {}/{}", app.team.len(), team::MAX_MEMBERS),
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::raw(""));
if app.team.is_empty() {
lines.push(Line::from(Span::styled(
format!(" {}", s.team_empty),
Style::default().fg(theme::overlay()),
)));
}
for (i, name) in app.team.iter().enumerate() {
let cursor = if i == app.team_cursor { "▶" } else { " " };
let pin = if app.is_pinned(name) { "◆" } else { " " };
let name_style = if i == app.team_cursor {
color::highlight(theme::mauve()).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::text())
};
let mut row = vec![
Span::styled(cursor, Style::default().fg(theme::mauve())),
Span::styled(pin, Style::default().fg(theme::teal())),
Span::styled(format!(" {:<12} ", title_case(name)), name_style),
];
match app.details.get(name) {
Some(detail) => row.extend(type_chips(&detail.types)),
None => row.push(Span::styled(
s.loading.to_string(),
Style::default().fg(theme::overlay()),
)),
}
lines.push(Line::from(row));
}
if !loaded.is_empty() {
let analysis = team::analyse(&loaded);
lines.push(Line::raw(""));
lines.push(section_heading(s.team_shared_weak));
if analysis.shared_weaknesses.is_empty() {
lines.push(all_clear(s));
} else {
let mut remaining = analysis.shared_weaknesses.as_slice();
while let Some(first) = remaining.first() {
let count = first.weak;
let split = remaining.partition_point(|row| row.weak == count);
let (group, rest) = remaining.split_at(split);
let types: Vec<&str> = group.iter().map(|row| row.attacker).collect();
let label = format!("{count}/{}", loaded.len());
lines.extend(chip_rows(&label, &types, text_w));
remaining = rest;
}
}
lines.push(Line::raw(""));
lines.push(section_heading(s.team_unresisted));
push_chip_section(&mut lines, &analysis.unresisted, text_w, s);
if !analysis.ability_immunities.is_empty() {
lines.push(Line::raw(""));
lines.push(section_heading(s.immune_by_ability));
for immunity in &analysis.ability_immunities {
let lead = format!(" {} · ", title_case(&immunity.pokemon));
lines.push(ability_immunity_row(app, s, immunity, &lead));
}
}
lines.push(Line::raw(""));
lines.push(section_heading(s.team_offense_gaps));
push_chip_section(&mut lines, &analysis.offense_gaps, text_w, s);
}
let height = (lines.len() as u16 + 3).min(full.height);
let area = centered_fixed(width, height, full);
frame.render_widget(Clear, area);
let block = Block::bordered()
.border_type(BorderType::Double)
.border_style(Style::default().fg(theme::mauve()))
.title(Span::styled(
s.team_title,
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme::surface()));
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(inner);
frame.render_widget(Paragraph::new(lines), rows[0]);
let hint = Paragraph::new(Line::from(Span::styled(
s.team_close_hint,
Style::default().fg(theme::overlay()),
)))
.alignment(Alignment::Center);
frame.render_widget(hint, rows[1]);
}
fn push_chip_section(lines: &mut Vec<Line<'static>>, types: &[&str], width: usize, s: &Strings) {
if types.is_empty() {
lines.push(all_clear(s));
} else {
lines.extend(chip_rows("", types, width));
}
}
fn all_clear(s: &Strings) -> Line<'static> {
Line::from(Span::styled(
format!(" {}", s.team_all_clear),
Style::default().fg(theme::green()),
))
}
fn render_compare(frame: &mut Frame, app: &App, s: &Strings, full: Rect) {
let Some((left, right)) = app.comparison() else {
return; };
let width = COMPARE_CARD_W.min(full.width);
let inner_w = width.saturating_sub(4) as usize;
let bar_w =
inner_w.saturating_sub(COMPARE_VAL_W * 2 + STAT_LABEL_WIDTH + COMPARE_MARGIN_W + 5) / 2;
if bar_w < 6 || full.height < 18 {
return; }
let rows = compare::stat_rows(left, right);
let peak = compare::peak(&rows);
let height = (rows.len() as u16 + 15).min(full.height);
let area = centered_fixed(width, height, full);
frame.render_widget(Clear, area);
let block = Block::bordered()
.border_type(BorderType::Double)
.border_style(Style::default().fg(theme::mauve()))
.title(Span::styled(
s.compare_title,
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme::surface()));
let inner = block.inner(area);
frame.render_widget(block, area);
let inner = Rect {
x: inner.x + 1,
width: inner.width.saturating_sub(2),
..inner
};
let body = Layout::vertical([
Constraint::Length(2), Constraint::Length(1), Constraint::Length(rows.len() as u16 + 2), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(inner);
let head =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(body[0]);
frame.render_widget(Paragraph::new(side_heading(left)), head[0]);
frame.render_widget(
Paragraph::new(side_heading(right)).alignment(Alignment::Right),
head[1],
);
let mut stat_lines: Vec<Line> = rows
.iter()
.map(|row| {
compare_row(
app.language.stat_label(row.kind),
row.left as u32,
row.right as u32,
Some((row.left, row.right, peak)),
bar_w,
s,
)
})
.collect();
stat_lines.push(Line::raw(""));
stat_lines.push(compare_row(
s.total_label,
left.stat_total(),
right.stat_total(),
None,
bar_w,
s,
));
frame.render_widget(Paragraph::new(stat_lines), body[2]);
frame.render_widget(Paragraph::new(section_heading(s.compare_best_hit)), body[4]);
let hits =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(body[5]);
frame.render_widget(Paragraph::new(best_hit_line(left, right)), hits[0]);
frame.render_widget(
Paragraph::new(best_hit_line(right, left)).alignment(Alignment::Right),
hits[1],
);
let facts =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(body[7]);
let fact_w = facts[0].width as usize;
frame.render_widget(Paragraph::new(side_facts(app, left, fact_w)), facts[0]);
frame.render_widget(
Paragraph::new(side_facts(app, right, fact_w)).alignment(Alignment::Right),
facts[1],
);
let hint = Paragraph::new(Line::from(Span::styled(
s.compare_hint,
Style::default().fg(theme::overlay()),
)))
.alignment(Alignment::Center);
frame.render_widget(hint, body[8]);
}
fn side_heading(species: &PokemonDetail) -> Vec<Line<'static>> {
vec![
Line::from(vec![
Span::styled(
title_case(&species.name),
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" #{:04}", species.dex_number),
Style::default().fg(theme::overlay()),
),
]),
Line::from(type_chips(&species.types)),
]
}
fn compare_row(
label: &str,
left: u32,
right: u32,
bars: Option<(u16, u16, u16)>,
bar_w: usize,
s: &Strings,
) -> Line<'static> {
let winner = compare::side(left, right);
let (left_color, right_color) = match winner {
compare::Side::Left => (theme::green(), theme::overlay()),
compare::Side::Right => (theme::overlay(), theme::green()),
compare::Side::Tie => (theme::lavender(), theme::lavender()),
};
let emphasis = |side| match winner == side {
true => Modifier::BOLD,
false => Modifier::empty(),
};
let (left_fill, right_fill) = match bars {
Some((l, r, peak)) => (fill(l, peak, bar_w), fill(r, peak, bar_w)),
None => (0, 0),
};
Line::from(vec![
Span::raw(" ".repeat(bar_w - left_fill)),
Span::styled("█".repeat(left_fill), Style::default().fg(left_color)),
Span::styled(
format!(" {left:>COMPARE_VAL_W$} "),
Style::default()
.fg(left_color)
.add_modifier(emphasis(compare::Side::Left)),
),
Span::styled(
format!("{label:^STAT_LABEL_WIDTH$}"),
Style::default().fg(theme::subtext()),
),
Span::styled(
format!(" {right:<COMPARE_VAL_W$} "),
Style::default()
.fg(right_color)
.add_modifier(emphasis(compare::Side::Right)),
),
Span::styled("█".repeat(right_fill), Style::default().fg(right_color)),
Span::raw(" ".repeat(bar_w - right_fill)),
Span::styled(
format!(" {:<COMPARE_MARGIN_W$}", margin_label(left, right, s)),
Style::default().fg(match winner {
compare::Side::Tie => theme::overlay(),
_ => theme::green(),
}),
),
])
}
fn fill(value: u16, peak: u16, bar_w: usize) -> usize {
if value == 0 || peak == 0 {
return 0;
}
((value as usize * bar_w) / peak as usize).clamp(1, bar_w)
}
fn margin_label(left: u32, right: u32, s: &Strings) -> String {
match compare::side(left, right) {
compare::Side::Left => format!("◀ {}", left - right),
compare::Side::Right => format!("▶ {}", right - left),
compare::Side::Tie => s.compare_tie.to_string(),
}
}
fn best_hit_line(attacker: &PokemonDetail, defender: &PokemonDetail) -> Line<'static> {
let Some(hit) = compare::best_hit(attacker, defender) else {
return Line::raw("");
};
let label = typechart::multiplier_label(hit.multiplier);
Line::from(vec![
Span::styled(
format!(" {} ", title_case(hit.attack_type)),
Style::default()
.fg(theme::base())
.bg(theme::type_color(hit.attack_type))
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {label}"),
Style::default()
.fg(match hit.multiplier > 1.0 {
true => theme::peach(),
false => theme::subtext(),
})
.add_modifier(Modifier::BOLD),
),
])
}
fn side_facts(app: &App, species: &PokemonDetail, width: usize) -> Vec<Line<'static>> {
let mut lines = vec![Line::from(Span::styled(
format!(
"{:.1} m · {:.1} kg",
species.height as f32 / 10.0,
species.weight as f32 / 10.0
),
Style::default().fg(theme::subtext()),
))];
let abilities: Vec<String> = species
.abilities
.iter()
.map(|ability| ability_display_name(app, &ability.name))
.collect();
if !abilities.is_empty() {
lines.extend(
wrap_plain(&abilities.join(" · "), width.max(8))
.into_iter()
.take(2)
.map(|text| Line::from(Span::styled(text, Style::default().fg(theme::text())))),
);
}
lines
}
const FORMS_CARD_W: u16 = 48;
fn render_forms(frame: &mut Frame, app: &App, s: &Strings, full: Rect) {
let Some(detail) = app.selected_detail() else {
return;
};
let width = FORMS_CARD_W.min(full.width);
let height = full
.height
.saturating_sub(4)
.min(detail.forms.len() as u16 + 4);
if width < 20 || height < 6 {
return; }
let area = centered_fixed(width, height, full);
frame.render_widget(Clear, area);
let block = Block::bordered()
.border_type(BorderType::Double)
.border_style(Style::default().fg(theme::mauve()))
.title(Span::styled(
s.forms_title,
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme::surface()));
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner);
let window = rows[0].height as usize;
let first = app
.forms_cursor
.saturating_sub(window / 2)
.min(detail.forms.len().saturating_sub(window));
let mut lines: Vec<Line> = Vec::with_capacity(window);
for (i, form) in detail.forms.iter().enumerate().skip(first).take(window) {
let selected = i == app.forms_cursor;
let shown = *form == detail.name;
let marker = if shown { "●" } else { "○" };
let label = format!(" {marker} {} ", form_label(form, &detail.species));
let style = if selected {
color::highlight(theme::mauve()).add_modifier(Modifier::BOLD)
} else if shown {
Style::default().fg(theme::mauve())
} else {
Style::default().fg(theme::text())
};
lines.push(Line::from(Span::styled(label, style)));
}
frame.render_widget(Paragraph::new(lines), rows[0]);
let hint = Paragraph::new(Line::from(Span::styled(
s.forms_close_hint,
Style::default().fg(theme::overlay()),
)))
.alignment(Alignment::Center);
frame.render_widget(hint, rows[1]);
}
fn render_language_picker(frame: &mut Frame, app: &App, s: &Strings, full: Rect) {
let width = 26u16;
let height = Language::ALL.len() as u16 + 4; let area = centered_fixed(width, height, full);
frame.render_widget(Clear, area);
let block = Block::bordered()
.border_type(BorderType::Double)
.border_style(Style::default().fg(theme::mauve()))
.title(Span::styled(
s.language_title,
Style::default()
.fg(theme::mauve())
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme::surface()));
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(inner);
let mut lines: Vec<Line> = Vec::with_capacity(Language::ALL.len());
for (i, lang) in Language::ALL.iter().enumerate() {
let selected = i == app.lang_cursor;
let active = *lang == app.language;
let marker = if active { "●" } else { "○" };
let label = format!(" {marker} {:<10} {} ", lang.label(), lang.tag());
let style = if selected {
color::highlight(theme::mauve()).add_modifier(Modifier::BOLD)
} else if active {
Style::default().fg(theme::mauve())
} else {
Style::default().fg(theme::text())
};
lines.push(Line::from(Span::styled(label, style)));
}
frame.render_widget(Paragraph::new(lines), rows[0]);
let hint = Paragraph::new(Line::from(Span::styled(
"↑/↓ · Enter · Esc",
Style::default().fg(theme::overlay()),
)))
.alignment(Alignment::Center);
frame.render_widget(hint, rows[1]);
}
fn centered_fixed(width: u16, height: u16, area: Rect) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use crate::app::{app_listing, loaded};
use crate::color::Depth;
use crate::models::{Ability, Stat, StatKind};
fn frame_rows(app: &mut App, width: u16, height: u16) -> Vec<String> {
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("test backend");
terminal
.draw(|frame| render(frame, app))
.expect("a frame draws");
let buffer = terminal.backend().buffer().clone();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer.cell((x, y)).map_or(" ", |cell| cell.symbol()))
.collect()
})
.collect()
}
fn screen(app: &mut App, width: u16, height: u16) -> String {
frame_rows(app, width, height).join("\n")
}
fn right_column(app: &mut App, width: u16, height: u16) -> String {
let split = (f32::from(width) * 0.32) as usize;
frame_rows(app, width, height)
.iter()
.map(|row| row.chars().skip(split).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
fn showing_gengar() -> App {
let mut app = app_listing(&[(92, "gastly"), (93, "haunter"), (94, "gengar")]);
app.color_depth = Depth::None;
app.browser.recompute();
app.details.insert("gengar".to_string(), gengar());
app.selected_name = Some("gengar".to_string());
app
}
fn gengar() -> PokemonDetail {
PokemonDetail {
dex_number: 94,
types: vec!["ghost".to_string(), "poison".to_string()],
abilities: vec![Ability {
name: "cursed-body".to_string(),
is_hidden: false,
}],
stats: vec![
Stat {
kind: StatKind::Hp,
base: 60,
},
Stat {
kind: StatKind::SpecialAttack,
base: 130,
},
],
height: 15,
weight: 405,
..loaded("gengar")
}
}
#[test]
fn the_sidebar_lists_what_the_filter_left_and_counts_it() {
let mut app = showing_gengar();
app.browser.query = "ga".to_string();
app.browser.recompute();
let frame = screen(&mut app, 120, 40);
assert!(frame.contains("Gastly"), "{frame}");
assert!(frame.contains("Gengar"));
assert!(!frame.contains("Haunter"), "filtered out, so not drawn");
assert!(frame.contains("(2)"), "the title counts what survived");
assert!(frame.contains("▶"), "and the cursor sits on a row");
}
#[test]
fn the_sidebar_says_what_it_is_waiting_for_rather_than_drawing_an_empty_list() {
let s = Language::English.strings();
let mut app = app_listing(&[]);
app.color_depth = Depth::None;
app.list_loading = true;
assert!(screen(&mut app, 120, 40).contains(s.loading_list));
let mut app = showing_gengar();
app.browser.query = "type:ghost".to_string();
app.browser.recompute();
assert!(screen(&mut app, 120, 40).contains(s.loading_filter));
let mut app = showing_gengar();
app.browser.query = "zzz".to_string();
app.browser.recompute();
assert!(screen(&mut app, 120, 40).contains(s.no_results));
}
#[test]
fn the_detail_panel_draws_the_species_it_was_given() {
let mut app = showing_gengar();
let panel = right_column(&mut app, 120, 40);
assert!(panel.contains("Gengar"), "{panel}");
assert!(panel.contains("#0094"), "the dex number, padded");
assert!(
panel.contains("Ghost") && panel.contains("Poison"),
"type chips"
);
assert!(
panel.contains("Cursed Body"),
"abilities come free with the record"
);
assert!(
panel.contains("1.5 m") && panel.contains("40.5 kg"),
"measurements"
);
assert!(
panel.contains("Total: 190"),
"the stat total is summed, not stored"
);
}
#[test]
fn the_detail_panel_says_when_there_is_nothing_to_show_yet() {
let s = Language::English.strings();
let mut app = app_listing(&[(94, "gengar")]);
app.color_depth = Depth::None;
app.browser.recompute();
assert!(right_column(&mut app, 120, 40).contains(s.no_selection));
let mut app = app_listing(&[(94, "gengar")]);
app.color_depth = Depth::None;
app.browser.recompute();
app.selected_name = Some("gengar".to_string());
app.loading_detail = Some("gengar".to_string());
assert!(right_column(&mut app, 120, 40).contains(s.loading));
let mut app = app_listing(&[(94, "gengar")]);
app.color_depth = Depth::None;
app.browser.recompute();
app.error = Some("the network is down".to_string());
assert!(right_column(&mut app, 120, 40).contains("the network is down"));
}
#[test]
fn the_evolution_panel_says_when_a_species_has_no_chain() {
let s = Language::English.strings();
let mut app = showing_gengar();
assert!(right_column(&mut app, 120, 40).contains(s.no_evolution));
}
#[test]
fn a_chain_that_fits_is_drawn_as_cards_and_one_that_does_not_falls_back_to_the_tree() {
let mut app = showing_gengar();
app.evolutions.insert("gengar".to_string(), chain(3, 1));
let cards = right_column(&mut app, 120, 40);
assert!(cards.contains("Stage"), "{cards}");
assert!(
!cards.contains("└── ") && !cards.contains("├── "),
"cards, not the tree: {cards}"
);
app.evolutions.insert("gengar".to_string(), chain(2, 8));
let tree = right_column(&mut app, 120, 40);
assert!(
tree.contains("└── ") || tree.contains("├── "),
"the tree, not cards: {tree}"
);
}
fn chain(depth: usize, leaves: usize) -> EvolutionTree {
let mut node = EvolutionTree {
name: "leaf".to_string(),
condition: None,
children: Vec::new(),
};
for _ in 1..depth {
node = EvolutionTree {
name: "stage".to_string(),
condition: None,
children: vec![node],
};
}
let deepest = (1..depth).fold(&mut node, |n, _| &mut n.children[0]);
for _ in 1..leaves {
deepest.children.push(EvolutionTree {
name: "branch".to_string(),
condition: None,
children: Vec::new(),
});
}
node
}
fn canvas(width: u16, height: u16) -> Rect {
Rect {
x: 0,
y: 0,
width,
height,
}
}
fn fact(label: &str, value: &str) -> (String, String) {
(label.to_string(), value.to_string())
}
fn row_text(rows: &[Line]) -> Vec<String> {
rows.iter().map(|line| line.to_string()).collect()
}
#[test]
fn facts_pack_into_a_row_until_it_is_full() {
let facts = [fact("A", "one"), fact("B", "two"), fact("C", "three")];
assert_eq!(
row_text(&fact_rows(&facts, 20)),
["A: one B: two", "C: three"]
);
assert_eq!(
row_text(&fact_rows(&facts, 40)),
["A: one B: two C: three"]
);
}
#[test]
fn a_fact_wider_than_the_column_still_gets_a_row() {
let facts = [fact("Habitat", "Somewhere very far away indeed")];
assert_eq!(fact_rows(&facts, 10).len(), 1);
assert!(fact_rows(&[], 10).is_empty());
}
#[test]
fn a_label_row_wraps_under_its_value_and_loses_nothing() {
assert_eq!(
row_text(&label_rows("Forms", "Alola · Gmax", 40)),
["Forms: Alola · Gmax"]
);
assert_eq!(
row_text(&label_rows("Forms", "Alola · Gmax", 14)),
["Forms: Alola ·", " Gmax"]
);
}
#[test]
fn a_fact_with_no_label_is_its_own_explanation() {
assert_eq!(
row_text(&fact_rows(&[fact("", "♂ 50% · ♀ 50%")], 40)),
["♂ 50% · ♀ 50%"]
);
}
#[test]
fn the_field_rows_say_genderless_and_leave_out_what_the_record_lacks() {
let s = Language::English.strings();
let mut field = FieldData {
egg_groups: vec!["monster".to_string(), "plant".to_string()],
capture_rate: 45,
base_happiness: Some(50),
growth_rate: Some("medium-slow".to_string()),
gender_rate: 1,
habitat: Some("grassland".to_string()),
};
let text = row_text(&field_rows(&field, &s, 200)).join(" ");
assert!(text.contains("Egg groups: Monster · Grass"), "{text}");
assert!(text.contains("♂ 87.5% · ♀ 12.5%"), "{text}");
assert!(text.contains("Catch rate: 45 (hard)"), "{text}");
assert!(text.contains("Growth: Medium Slow"), "{text}");
assert!(text.contains("Habitat: Grassland"), "{text}");
field.gender_rate = -1;
field.habitat = None;
field.base_happiness = None;
field.growth_rate = None;
let text = row_text(&field_rows(&field, &s, 200)).join(" ");
assert!(text.contains("Genderless"), "{text}");
assert!(
!text.contains("Habitat"),
"a null habitat drops the row: {text}"
);
assert!(!text.contains("None"), "{text}");
assert!(!text.contains("Growth"), "{text}");
}
#[test]
fn a_two_stage_chain_gets_cards_in_a_panel() {
assert_eq!(card_grid(canvas(60, 16), 2, 2), Some((30, 8)));
}
#[test]
fn a_column_never_grows_past_the_card_cap() {
assert_eq!(
card_grid(canvas(130, 16), 2, 2),
Some((MAX_CARD_W + EVO_GAP, 8))
);
}
#[test]
fn eevees_eight_branches_do_not_fit_the_panel() {
assert_eq!(card_grid(canvas(120, 18), 2, 8), None);
assert!(card_grid(canvas(120, 40), 2, 8).is_some());
}
#[test]
fn a_chain_too_wide_for_its_columns_falls_back() {
assert_eq!(card_grid(canvas(80, 60), 9, 1), None);
}
#[test]
fn an_empty_canvas_is_not_divided_by_zero() {
assert_eq!(card_grid(canvas(0, 0), 0, 0), None);
}
#[test]
fn the_hint_row_prefers_the_cursors_requirement_over_the_key_map() {
let s = Language::English.strings();
let tree = chain(2, 1);
let plain = chain_hint(&tree, None, &s, "keys");
assert_eq!(plain.spans.len(), 1);
assert_eq!(plain.spans[0].content, "keys");
assert_eq!(
chain_hint(&tree, Some("leaf"), &s, "keys").spans[0].content,
"keys"
);
}
}