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, SortKey};
use crate::i18n::{EvoStrings, Language, Strings};
use crate::models::{title_case, EvolutionTree, Sprite};
use crate::team;
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.team_card {
render_team(frame, app, &strings, area);
}
if app.language_picker {
render_language_picker(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.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.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.sort {
SortKey::Dex => s.sort_dex,
SortKey::Name => s.sort_name,
};
let title = format!(
"{}({}) ⇅ {} ",
s.sidebar_title,
app.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_type_roster() {
render_centered_loading(frame, inner, s.loading_types, app.spinner);
return;
}
if app.filtered.is_empty() {
render_centered_text(frame, inner, s.no_results, theme::OVERLAY);
return;
}
let items: Vec<ListItem> = app
.filtered
.iter()
.filter_map(|&idx| app.all_pokemon.get(idx))
.map(|p| {
let dex = match p.dex_number() {
Some(number) => format!("{number:>4} "),
None => " ".repeat(5),
};
let in_team = app.is_in_team(&p.name);
ListItem::new(Line::from(vec![
Span::styled(
if in_team { "● " } else { " " },
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(
Style::default()
.fg(theme::BASE)
.bg(theme::MAUVE)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, inner, &mut app.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 label = format!("{}: ", s.abilities_label);
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();
let indent = " ".repeat(label.chars().count());
let budget = (info.width as usize).saturating_sub(label.chars().count());
for (row, text) in wrap_plain(&entries.join(" · "), budget.max(8))
.into_iter()
.enumerate()
{
lines.push(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)),
]));
}
}
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 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 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;
Color::Rgb(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);
let canvas = rows[0];
let depth = tree.depth() as u16;
let leaves = tree.leaf_count() as u16;
let col_w = canvas.width.checked_div(depth).unwrap_or(0);
let lane_h = canvas.height.checked_div(leaves).unwrap_or(0);
if col_w >= MIN_CARD_W && lane_h >= MIN_CARD_H {
let mut lane = 0u16;
place_node(
frame, app, s, tree, current, cursor, canvas, col_w, lane_h, 0, &mut lane,
);
} else {
let lines = evolution_lines(tree, cursor.or(current), &s.evo, canvas.width);
frame.render_widget(Paragraph::new(lines), canvas);
}
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());
let hint = 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(
if focused {
s.evo_nav_hint
} else {
s.expand_hint
},
Style::default().fg(theme::OVERLAY),
)),
};
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Center), rows[1]);
}
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;
#[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 {
Style::default()
.fg(theme::BASE)
.bg(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 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(""));
lines.push(section_heading(s.matchups_defense));
for group in typechart::defensive_groups(&detail.types) {
lines.extend(chip_rows(group.label, &group.types, text_w));
}
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),
("T", h.act_types),
("A", h.act_abilities),
("X", h.act_shiny),
("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),
("gen:1", h.act_by_generation),
("", ""),
("", h.ctx_evolution),
("← → ↑ ↓ · h j k l", h.act_chain_move),
("Enter", h.act_chain_jump),
("X", h.act_shiny),
("Esc · Tab", h.act_back),
("", ""),
("", 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_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_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 name in &app.team {
let mut row = vec![Span::styled(
format!(" {:<12} ", title_case(name)),
Style::default().fg(theme::TEXT),
)];
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.team_ability_immunity));
for immunity in &analysis.ability_immunities {
let mut row = vec![
Span::styled(
format!(" {} · ", title_case(&immunity.member)),
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.team_maybe),
Style::default().fg(theme::OVERLAY),
));
}
lines.push(Line::from(row));
}
}
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_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 {
Style::default()
.fg(theme::BASE)
.bg(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,
}
}