pokeductor 0.5.0

A terminal Pokedex and evolution analyzer with sprite rendering, offline type and party analysis, and an on-disk cache for offline use
pokeductor-0.5.0 is not a library.

Pokeductor

A terminal Pokédex and evolution analyzer, powered by PokeAPI.

Browse every species, read localized Pokédex entries, study branching evolution chains as connected sprite cards, and analyse type coverage for a single species or a whole party — rendered with Unicode half-blocks in a PICO-8-inspired palette. Built in Rust with ratatui.

CI Rust MSRV ratatui Async License

A short tour — filtering by type and generation, type matchups, abilities, building a party, and the help overlay:


Install

Prebuilt binaries

No toolchain needed. Each release carries an archive per platform, with a .sha256 beside it:

Platform Archive
Linux (any distribution) x86_64-unknown-linux-musl.tar.gz
Linux (glibc) x86_64-unknown-linux-gnu.tar.gz
macOS (Apple silicon) aarch64-apple-darwin.tar.gz
macOS (Intel) x86_64-apple-darwin.tar.gz
Windows x86_64-pc-windows-msvc.zip

Take the musl one on Linux unless you have a reason not to: it is statically linked and carries no glibc floor, so it runs on distributions older than the machine it was built on. Both Linux builds link no system OpenSSL.

tar xzf pokeductor-v0.5.0-x86_64-unknown-linux-musl.tar.gz
./pokeductor-v0.5.0-x86_64-unknown-linux-musl/pokeductor

With cargo

cargo install pokeductor

Or from a clone:

cargo run --release

Arch Linux

Packaged in the AUR, built from the crates.io release:

yay -S pokeductor

Requirements

  • Rust 1.88 or newer (2021 edition) — via rustup. Only for building it yourself: the prebuilt binaries and the AUR package need no toolchain.
  • A truecolor (24-bit) terminal for sprites at their best. Not a requirement: a 256-colour terminal gets the artwork quantized to its palette, and one with no colour at all gets the interface without sprites rather than a field of blank blocks. See Colour.
  • A font with Unicode block and box-drawing glyphs — any Nerd Font, Fira Code, JetBrains Mono, and most modern monospace fonts qualify.
  • An internet connection for anything not already cached. After a species has been viewed once it opens with no network at all; see Caching.

The first launch fetches the species list and opens on Bulbasaur — or on whatever you name, pokeductor gengar. Moving through the list loads each Pokémon's details, evolution chain and artwork on demand. See Command line for the full set of flags.


The interface

Browsing and filtering

The sidebar lists every entry PokeAPI serves — 1351 of them today, species and alternate forms alike — with National Pokédex numbers. A bare number looks up that Pokédex number — 25 finds Pikachu — and beyond plain name matching the search box takes dex:, type:, ability:, egg: and gen: terms:

type:, ability: and egg: terms combine with AND, within one kind and across kinds: type:water type:flying finds the dual-typed ones, and type:dragon ability:levitate finds the two conditions met at once. gen: and dex: terms combine with OR, since a species belongs to exactly one generation and carries exactly one number, so requiring two at once could only ever match nothing. Anything that is not a recognised term is treated as ordinary text, so a stray colon degrades to a name search instead of an error. S cycles the sort between Pokédex order and alphabetical; the highlighted species stays under the cursor across a re-sort or a narrowing search.

R loads a species at random from whatever the list is narrowed to — type:ghost then R is a random Ghost, gen:1 then R is a random Kanto species — which is the one way through thirteen hundred entries that does not need you to know what you are looking for.

Species details and abilities

The info panel carries the dex number, genus, typing, abilities, physical measurements and base stats, then the field-guide half of the entry: breeding groups, gender ratio, catch rate, growth curve, base happiness and — for species up to Generation IV, the last the games recorded one for — habitat. A flavour blurb follows when the panel has room for one. All of it arrives on the species record every fetch already pulls down, so none of it costs a request.

The catch rate is 0–255 with higher being easier, which the number alone does not say, so it comes with a word — hard up to 45 (starters, pseudo- legendaries, everything rarer), easy from 150 (early-route species) — and a genderless species says so rather than showing a ratio. Breeding groups are named as the games name them, not as PokeAPI files them: the egg: search term's alias table, run the other way.

A species that ships in more than one form lists the others in a Forms row, so Raichu's card says an Alolan form exists — something the list alone never did, since a form is only reachable there by already knowing its name. V opens the forms card, a short list with the form on display marked, and Enter loads the one under the cursor. Forms are ordinary entries in the master list, so the jump selects one there exactly as the evolution panel selects a stage.

A opens the abilities card:

Ability names arrive inside the payload every species fetch pulls down already, so listing them on the info card costs nothing. Their descriptions live behind one request each and are cached permanently. Hidden abilities are marked: they are only obtainable by special means, which is worth knowing at a glance.

Moves

M opens the learnset: what the species learns, how, and at what level, with each move's type, category, power, accuracy and PP, and a description of the one under the cursor.

The rows come free with the species record — /pokemon/{name} already answers with them — so the card costs no request to open. What it does not carry is what each move does, which lives on /move/{name}; those are fetched a screenful at a time as you scroll, and cached permanently, so a species read twice is read offline the second time.

PokeAPI repeats a learnset once per set of games, which for a Generation I species is twenty-odd copies of the same list. The card shows one: the newest games the species appears in, named beside its own name so there is no doubt which. Level-up moves come first and in the order they are learned, then egg moves, then TMs and tutor moves alphabetically.

Evolution chains

Chains are laid out as connected sprite cards, each stage labelled with what it takes to reach it. Focus the panel with E to move between stages; Enter jumps to the highlighted one, and the full set of conditions for the stage under the cursor is spelled out beneath the panel.

Branching chains are handled as an n-ary tree, so every route is represented. When there are too many branches to draw as cards in the space available, the panel falls back to a labelled list rather than truncating:

F expands the chain to the full terminal, which is how the widest lines get to use the card layout at all: every branch needs its own lane, so Eevee's eight want more rows than the panel is ever given. The cursor and the requirement readout work exactly as they do in the panel; Enter loads the stage under the cursor and collapses back to it, since what you picked is on the panels the chain was covering, and Esc collapses without taking one. The labelled list remains the last resort — a nine-node chain still will not fit an 80×24 window, full screen or not.

Shiny artwork

X flips every sprite on screen — the info panel and each card in the evolution chain — into its shiny palette, so a whole line can be inspected in the colours it is hunted for. The toggle is app-wide rather than per-species: it stays on as you move through the list, and the info panel carries a ✦ Shiny badge while it does, since an unfamiliar palette otherwise reads as a rendering bug.

Only the palette on screen is fetched, so flipping the toggle never pulls down two full sets of artwork, and both are cached separately on disk. A species PokeAPI ships no shiny sprite for falls back to its normal one.

Palettes

--theme picks the palette the whole interface is drawn in, and the choice is kept between runs alongside the language and the sort order:

Palette
pico8 PICO-8's colours: warm yellows on a matte navy. The default, and what every screenshot here was taken in.
dmg The Game Boy DMG's greens.

The Game Boy palette is not a tint over the same picture. Sprites are quantized to the DMG's four shades the way the hardware quantized everything it was ever shown, because artwork left in full colour over a green interface would only look broken. Stat bars carry their scale in brightness rather than in hue — there is one hue — climbing out of the background towards the brightest green the screen had. Type chips are all drawn in the same shade: eighteen distinguishable hues is more than a Game Boy has, and the type's name is written in the chip in either palette.

Both palettes go through the same colour-depth pass below, so --theme dmg --color 256 is greens quantized to the xterm palette, and NO_COLOR is the same colourless interface either way.

Colour

Sprites are RGB half-blocks, so how they land depends on what the terminal can show. Rather than requiring 24-bit colour, Pokeductor works out what it has and renders to it:

Terminal What you get
Truecolor (COLORTERM=truecolor) The artwork as decoded, 24-bit.
256 colours Sprites quantized to the xterm palette — the 6×6×6 cube plus the greyscale ramp — so they stay readable instead of being dropped by a terminal that cannot parse the escape.
No colour (NO_COLOR, TERM=dumb, --color=never) The full interface without sprites. A sprite with no colour is a rectangle of identical blocks, which says less than the placeholder the panels already fall back to.

Detection is deliberately conservative: COLORTERM is the only positive claim of 24-bit support that gets taken at its word, so a terminal announcing itself through TERM alone gets the 256-colour path even when it would in fact have managed truecolor. A coarser sprite is a much smaller cost than an unreadable one, and --color=truecolor is there for anyone who knows better.

NO_COLOR is honoured, and --color outranks it — naming a depth on the command line answers the question the environment is being consulted about. With no colour to work with, the list cursor and the evolution highlight fall back to reverse video, so there is still always something showing where you are.

Type matchups

T opens the defensive and offensive breakdown for the current species:

Computed offline from a built-in Generation VI+ chart, so it opens instantly and works with no connection. Neutral matchups are omitted — they are the default, and listing them would bury the rows worth reading.

Head to head

C pins the species on display; C again on a second one opens the comparison. Each row is a pair of bars growing outwards from the stat's label, so which of the two is bulkier or faster arrives before any of the numbers are read, with the margin and an arrow at the end of the row for the ones that are. The bars scale to the biggest number on the card rather than to the 255 a stat can theoretically reach — against the theoretical ceiling every ordinary species draws the same short bar, which is exactly the difference the card exists to show.

Underneath, the hardest hit each side's own typing lands on the other. Same-type moves are the ones a species certainly has access to, which makes that the decisive number rather than a guess at a moveset, and a certain ability immunity overrules the chart here just as it does on the matchup card.

The pinned species carries a in the list until it is unpinned, which C on it does. It has a column of its own beside the of party membership, so a species that is both shows both. Both sides are records the app already holds, so the card costs no request to open — and two is the number it takes: three or more is a table nobody can read in a terminal, and "how does this group look together" is the party card's question.

The party card is also a shortlist to compare from: move over its members and C pins the one under the cursor, exactly as it would from the list. C on a second member puts that member on display and opens the same comparison the list would have, so "how do these two of mine compare" is answered without leaving the card to find them both again.

Party analysis

Space puts up to six Pokémon on a team; P shows the verdict on their combined typings:

A party asks a different question than a single species does, and the answer is not the union of its members' weaknesses — it is the overlap:

Section What it means
Shared weaknesses Attacking types hitting two or more members super-effectively, labelled with how many. A type that hits one member is that member's problem, not the team's.
Resisted by nobody Attacking types no member resists or is immune to. There is no safe switch-in against them.
Immune by ability Immunities the type chart cannot see (Levitate, Water Absorb, Flash Fire, …).
Hit hard by nobody Defending types no member hits super-effectively with a same-type move. These wall the team.

Note the differing thresholds: the first section asks "how many at once", the others ask "is there any answer at all". An empty section is good news, and the card says so rather than leaving a blank.

Ability immunities are deliberately kept out of the numbers. A species carries one of its listed abilities, not all of them, so an immunity is only a certainty when the species had no other ability it could have had — anything else is flagged as merely possible. Folding that uncertainty into the counts would make the card claim something the data does not support.

The party is picked up where it was left: it is written out on exit and restored on the next run, along with the language, the palette and the sort order. See Session state.

Help

Every binding in one place, grouped by where it applies:

Localization

Six interface languages, switchable live from a picker card with no restart and no refetch:


Key bindings

Context Key Action
List · j k Move selection
PgUp PgDn Jump ten
Enter Load the highlighted Pokémon
/ · Tab Focus the search box
E Focus the evolution panel
F Full-screen evolution chain
T Type matchup card
C Pin the species / compare two
A Ability card
M Moves card
V Alternate forms card
X Toggle shiny artwork
R Load a random species from the current filter
Space Add / remove from the party
P Party card
S Cycle sort: Pokédex order ↔ A–Z
L Language picker
? Help overlay
Q · Esc Quit
Search box type Filter the list
Enter Load the result and return to the list
Esc · Tab Back to the list
Evolution panel · h j k l Move between stages
Enter Jump to the highlighted stage
F Expand to full screen
X Toggle shiny artwork
Esc · Tab Back to the list
Full-screen chain · h j k l Move between stages
Enter Jump to the highlighted stage
F · Esc Close
Party card · j k Move between members
C Pin the member / compare two
P · Esc Close
Forms card · j k Move between forms
Enter Load the highlighted form
V · Esc Close
Moves card · j k Move between moves
PgUp PgDn · Home End Jump ten · to either end
M · Esc Close
Any card Esc Close
Anywhere Ctrl-C Quit

Search syntax

Query Matches
char names containing "char"
25 Pokédex number 25 — Pikachu — or a name containing "25"
dex:25 Pokédex number 25, without the name fallback (d: also works)
dex:1-151 every number in that range — the Kanto dex
type:water every Water Pokémon (t: also works)
type:water type:flying Water and Flying — Gyarados, Mantine, …
ability:levitate every Pokémon that can have Levitate (a: also works)
egg:dragon every species in the Dragon breeding group (e: also works)
gen:1 introduced in Generation I (g: also works)
gen:1 gen:2 either generation
gen:1 type:ghost ga all three at once

dex: and gen: filters skip alternate forms such as raichu-alola: their ids sit above 10000 and are not dex numbers, so there is nothing to test a range or derive a generation from. A bare number still reaches them by name. egg: skips them too, for the same kind of reason: breeding groups are recorded against species rather than against forms.

Breeding groups answer to their in-game names as well as to PokeAPI's older spellings, so egg:grass, egg:field, egg:human-like and egg:amorphous reach the groups the API files under plant, ground, humanshape and indeterminate.


Command line

Usage: pokeductor [OPTIONS] [NAME]

Arguments:
  [NAME]  Open directly on this species, e.g. `pokeductor gengar`

Options:
      --lang <LANG>   Start in this UI language [possible values: en, tr, de, fr, es, it]
      --color <WHEN>  How much colour the terminal can show [default: auto] [possible values: auto, truecolor, 256, never]
      --theme <PALETTE>  Draw the interface in this palette [possible values: pico8, dmg]
      --clear-cache   Delete the on-disk cache and exit
      --cache-dir     Print the cache directory and exit
  -h, --help          Print help (see more with '--help')
  -V, --version       Print version

NAME goes into the search box rather than through a parser of its own, so everything the search syntax understands works here too:

pokeductor gengar          # straight to Gengar
pokeductor 25              # Pokedex number 25 — Pikachu
pokeductor type:ghost      # open with the list already filtered

An exact name wins the cursor even when something longer sorts ahead of it — pokeductor mew opens Mew, not Mewtwo — and a name that matches nothing leaves you on the same empty list typing it would have, with the query still in the box saying why.

--lang outranks the language the previous run left behind for this run, and being an ordinary choice like any made from the picker, it is what gets stored on the way out.

--color overrides what colour detection concluded, in either direction: --color=truecolor on a terminal that never advertised it, or --color=never on one that did.

--theme picks the palette, and behaves exactly as --lang does against the stored one: it outranks what the last run left, and is itself what this run stores on the way out.

The two cache commands answer the question this README used to answer with a path and a rm -rf. Both print what they touched:

$ pokeductor --cache-dir
/home/you/.cache/pokeductor
$ pokeductor --clear-cache
Removed /home/you/.cache/pokeductor

Architecture

A layered design. The rendering layer is a pure function of application state, all network work happens off the UI thread, and everything fetched is written through to disk.

Module Responsibility
main.rs Entry point; argument handling, terminal setup and the tokio runtime.
models.rs API-agnostic domain types (PokemonDetail, EvolutionTree, Sprite, Ability).
api.rs Async PokeAPI client, evolution-chain parser, sprite decode, translation.
cache.rs On-disk cache of every fetched response, for instant and offline starts.
cli.rs Argument parsing, and the commands that answer without a terminal.
color.rs Terminal colour-depth detection, and the per-frame degradation pass.
session.rs Party and preferences carried over from the previous run.
query.rs Search-box syntax (dex:, type:, gen:) parsing.
browser.rs The sidebar's state: master list, filter, ordering and cursor — no client, no tasks.
app.rs State machine and tokio::select! event loop (input · messages · animation tick).
ui.rs All ratatui rendering, including the sprite and evolution-graph drawing.
typechart.rs Offline Generation VI+ type-effectiveness chart and matchup analysis.
team.rs Team-level type analysis built on top of the chart.
compare.rs Head-to-head arithmetic for two species: stat rows, winners, best same-type hit.
i18n.rs Language enum and translation tables for the six UI languages.
theme.rs The palettes — PICO-8 and Game Boy DMG — and the per-type accent colours.

Concurrency

Background fetch tasks are producers that send Messages over an mpsc channel; the main loop is the single consumer, draining that channel alongside terminal input and an animation tick via tokio::select!. The UI thread never blocks on I/O, and no state is shared across tasks — a task owns what it needs and hands the result back as a message.

The tick is not steady: it is selected on only while something is in flight, which is the only time a spinner is on screen to animate. Idle, the loop blocks on input and messages alone and draws when one of them says something changed, so a Pokédex left open in a split costs nothing at all — an idle measurement goes from ~1.1% of a core to 0.00%. Waking up uses MissedTickBehavior::Delay, so a ticker whose deadline went by during a long sleep fires once and schedules the next a full period out, rather than bursting through every frame it missed.

Caching

Two layers, both keyed by name.

In memory, a given Pokémon is fetched at most once per session. On disk, under $XDG_CACHE_HOME/pokeductor (falling back to ~/.cache/pokeductor):

list.json                    master species list, 30-day TTL
species/<name>.json          details + parsed evolution tree
sprites/<name>.png           decoded artwork, re-encoded as PNG
types/<type>.json            roster backing a type: filter
ability-members/<slug>.json  roster backing an ability: filter
egg-groups/<slug>.json       roster backing an egg: filter
abilities/<slug>.json        localized ability name and description
moves/<slug>.json            one move's typing, numbers and description
translations/<name>.<lang>.txt

Every fetch reads through the disk cache first and writes back only what it had to fetch, so a species already seen needs no request at all and the app keeps working with no connection. PokeAPI is effectively an append-only archive — a species' stats, typing and evolution chain do not change once published — so only the master list carries a TTL, and a stale list is still shown while a refresh is attempted in the background rather than replaced by an error.

Writes go through a temporary file and a rename, so an interrupted run cannot leave a half-written entry for the next one to read back as valid. Every entry is version-stamped: a build whose cached representation has changed shape treats older files as misses instead of mis-parsing them. The whole layer is best-effort — a cache that cannot be read or written is a miss, never an error the user sees. It is safe to delete at any time; it refills itself, and --clear-cache does it without anyone having to work out the path first.

Session state

What the cache holds is a second copy of something PokeAPI already knows, so deleting it costs nothing but a refetch. The choices made during a run are the opposite — nothing can reconstruct the party someone assembled — so they are kept apart from it, under $XDG_STATE_HOME/pokeductor (falling back to ~/.local/state/pokeductor):

session.json              party, language, sort order, palette, shiny toggle

Written once, as the app exits, and read once, before the first frame. A run that is killed rather than quit therefore leaves the previous session in place, and of two instances quitting in turn the last one wins — both acceptable for a convenience that never holds anything the user cannot rebuild in a few keystrokes.

The file is version-stamped and pretty-printed, and read back defensively: it sits in a directory users are invited to look inside, so a party longer than the six-member limit is trimmed rather than rejected, and a setting recorded in terms this build does not recognise — a language it no longer ships, say — leaves that setting at its default instead of discarding the whole file. Preferences are stored as codes ("tr", "name", "dmg") rather than as enum indices, so reordering an enum in Rust can never silently switch somebody's language.

Sprite pipeline

PokeAPI's front_default PNG (96×96) → decode to RGBA via image → crop to opaque bounds → box-average downscale, keeping aspect and accounting for the roughly 2:1 cell aspect ratio → alpha-blend over the panel colour → emit half-block cells, foreground being the top pixel and background the bottom.

Area averaging rather than nearest-neighbour sampling is what keeps downscaled sprites smooth instead of leaving the hard outline pixels as ragged lines.

The crop box is a property of the pixels, so it is worked out once when the sprite is decoded rather than on every draw. It used to be a full 96×96 scan per sprite per frame, and a frame showing an evolution chain draws ten of them — measured at 16.8 µs against the 16.9 µs the downsample itself costs, so caching it halves the sprite work in a frame. Sprite's fields are private for the same reason: a crop box stored beside the pixels must not be able to outlive them. Sprites are cached re-encoded as PNG rather than as raw RGBA: a few kilobytes compressed against ~36 KB flattened, and the decoder is already a dependency.

Colour depth

Detection resolves once at startup — COLORTERM, then TERM, with NO_COLOR and --color on top — into a single Depth carried on the app. The renderer never sees it: every widget writes 24-bit colour as before, and the finished buffer is rewritten on its way out of terminal.draw, mapping each cell's foreground and background to a palette index or to nothing at all.

Doing it over the buffer rather than at each call site is what makes it one rule instead of a condition threaded through nineteen hundred lines of rendering — and it catches the sprite cells for free, since by then they are ordinary coloured cells like any other.

Quantization compares two candidates: the nearest colour in the xterm 6×6×6 cube and the nearest step on the 24-entry greyscale ramp. Both are needed because the ramp is far finer than the cube's diagonal, so a mid-grey has a near-exact match on one and a visible cast on the other. Every palette entry quantizes to itself, which is the invariant the tests pin down.

One thing survives the loss of colour deliberately. The list cursor and the evolution highlight are written as a foreground/background pair plus REVERSED — which a coloured terminal simply swaps back, drawing exactly what it drew before, and a colourless one renders as reverse video. Written the usual way round the highlight bar would vanish under --color=never, and with it any way to tell where the cursor is.

crossterm reads NO_COLOR itself and strips colour sequences when it is set, so an explicit --color also calls force_color_output to say which of the two answers won.

Type and team analysis

typechart.rs holds the Generation VI+ chart as a pure function from (attacking type, defending type) to a multiplier, with the dual-type case derived by multiplying across the defender's types exactly as the games do. No round-trip is needed to answer "what is this weak to?".

team.rs builds on it. For each of the 18 attacking types it counts how many members take super-effective damage and whether anyone resists, and it derives offensive gaps from the union of the members' same-type coverage. Ability immunities come from a small static table of abilities that grant an outright immunity to a whole damage type. Abilities that merely soften a type (Thick Fat) belong to multipliers, and abilities keyed to a class of move rather than a type (Soundproof, Bulletproof) cannot be expressed as one, so neither is tabled.

Filtering and sorting

Generations are derived locally from a fixed table of dex ranges — released generations never gain or lose species — so gen: costs no request. type:, ability: and egg: each need a roster: the membership list /type/{name}, /ability/{name} and /egg-group/{name} return. One request answers a whole filter and is then cached permanently, which is what makes these filters affordable at all — the alternative would be fetching ~1300 species just to read one field off each. Rosters are stored a directory per kind, since the same word can name two of them: poison is a type and an ability both.

Sorting is deliberately limited to keys the list response already carries. Each entry's id is parsed out of the URL PokeAPI returns, which is what puts dex numbers in the sidebar for free and gives the generation filter something to work from. Ordering by base-stat total would mean those same ~1300 fetches for a single keypress.

Learnsets

A species record carries every move it has ever learned, listed once per set of games — for a Generation I species that is twenty-odd copies of the same entry. Showing them all would be unreadable and merging them would invent a movepool no game has, so one is chosen: the newest games the species appears in, which is the same reading the evolution panel takes in showing the current-generation route.

"Newest" is the highest version-group id that teaches something by levelling up. Both halves of that matter. The ids run in release order with two exceptions — PokeAPI appended the Japanese Generation I releases long after the fact, so their ids outrank modern ones and they are skipped by name. And the newest groups include ones like champions, which file a species' whole movepool under a method that carries no level; requiring a level-up entry is what keeps those from being read as a learnset.

Each move's own record is a request of its own, and a learnset runs past a hundred entries, so they are fetched a bandful at a time around the cursor rather than all at once when the card opens. Everything fetched is cached permanently.

Alternate forms

Forms such as raichu-alola resolve their species and evolution data via the base species name carried in the Pokémon payload, so they do not 404. Their ids sit above 10000 and carry no dex meaning, so they show a blank dex column and are excluded from dex: and gen: filters rather than being guessed at.

Which forms a species has is the varieties list on its species record, which the bundle already fetches, so the Forms row and the card behind V cost no request and are cached with the rest of the record. A form is named there by what it adds to the species — raichu-alola reads as Alola — and a name that does not start with the species, or the default variety whose name is the species itself, keeps all of it.

Evolution requirements

PokeAPI's evolution_details are parsed into a structured EvolutionCondition — level, item, held item, known move, happiness, affection, beauty, time of day, location, gender, trade species, party species, relative physical stats, and the one-off flags — and phrased through per-language templates, so each translation decides where the value lands (Use {} versus {} kullan). Where a stage can be reached more than one way, the first (current-generation) route is shown.

Localization

UI strings live in i18n.rs; add a language by extending the Language enum and Language::ALL, and adding a Strings table. Because the renderer re-reads these every frame, switching language updates the entire interface instantly with no extra bookkeeping.

Pokédex flavour text, genus, and ability names and descriptions come from PokeAPI in en, de, fr, es and it. Ability text is taken from the game flavour entries rather than the effect entries: PokeAPI carries flavour in all five of those languages, while effect text exists only in English, German and French.

Item, move and location names inside evolution requirements stay in English — they arrive as PokeAPI slugs, and localizing each would cost an extra request per name.

For a UI language PokeAPI has no text for (Turkish), the English blurb is translated on demand through the free, key-less MyMemory API and cached. This is best-effort: if the service errors or rate-limits, the English original is shown.

Dependencies

ratatui · crossterm · clap · tokio · reqwest · serde · serde_json · image · futures · anyhow · thiserror


Development

CONTRIBUTING.md is the short version of this section plus the house style: what CI runs, where a change belongs, and how comments, tests and commits are written here.

The checks CI enforces on every pull request, in the order it runs them:

cargo fmt --all --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features

Tests run on Linux, macOS and Windows. A separate job builds against the rust-version floor declared in Cargo.toml, so a dependency bump that raises the minimum supported Rust version fails the build rather than reaching users. cargo deny checks licenses and RUSTSEC advisories weekly and whenever the manifest changes.

Everything under typechart.rs, team.rs, query.rs and models.rs is pure and unit-tested; new logic belongs there rather than in app.rs or ui.rs wherever it can be expressed without a terminal or a network.

Releasing

Changes are recorded in CHANGELOG.md under Unreleased as they land. A release moves that section under its version number, bumps Cargo.toml, and is tagged:

git tag -a v0.5.0 -m "v0.5.0"
git push origin v0.5.0

The tag is the only manual step. Pushing it runs .github/workflows/release.yml, which re-runs the full check suite against the tagged tree, refuses to go on if the tag and the manifest disagree about the version or the changelog has no section for it, cross-compiles the five targets, attaches them with checksums to a GitHub release whose notes are that changelog section, and finally publishes to crates.io. Publishing needs a CARGO_REGISTRY_TOKEN repository secret.

crates.io is last because it is the step that cannot be undone, only yanked. The AUR package is updated by hand afterwards, since it builds from the crates.io tarball and cannot be prepared before it exists.

Credits

  • Data and sprites: PokeAPI. Please respect their fair-use policy — the on-disk cache exists partly so this client asks for each resource once and never again.
  • Translation fallback: MyMemory.
  • Pokémon is © Nintendo / Game Freak / The Pokémon Company. This is a non-commercial, educational project.

License

MIT — see LICENSE.