diff --git a/.github/workflows/binaries.yaml b/.github/workflows/binaries.yaml index 97d5ad2..284b65c 100644 --- a/.github/workflows/binaries.yaml +++ b/.github/workflows/binaries.yaml @@ -52,6 +52,8 @@ jobs: tool: cross - name: Build + env: + MAL_CLIENT_ID: ${{ secrets.MAL_CLIENT_ID }} run: cross build --release --target ${{ matrix.target }} - name: Prepare artifacts diff --git a/Cross.toml b/Cross.toml new file mode 100644 index 0000000..fe33873 --- /dev/null +++ b/Cross.toml @@ -0,0 +1,5 @@ +# `cross` runs the build inside a Docker container; host env vars are not +# visible there unless explicitly passed through. Forward the OAuth client id +# (set from a GitHub Actions secret in CI) so build.rs can read it. +[build.env] +passthrough = ["MAL_CLIENT_ID"] diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..23b0bab --- /dev/null +++ b/build.rs @@ -0,0 +1,38 @@ +use std::path::Path; + +// Injects the MAL OAuth client id into the binary at compile time so it is not +// hard-coded in the source. Resolution order: +// 1. the `MAL_CLIENT_ID` environment variable (CI release builds set this +// from a GitHub Actions secret), +// 2. a `MAL_CLIENT_ID=...` line in a local, gitignored `.env` file. +// +// Note: a PKCE/native-app client id is a public identifier, not a true secret +// (it appears in the browser auth URL and can be extracted from any binary). +// This only keeps it out of the committed source, it does not make it private. +fn main() { + println!("cargo:rerun-if-env-changed=MAL_CLIENT_ID"); + println!("cargo:rerun-if-changed=.env"); + + let client_id = std::env::var("MAL_CLIENT_ID") + .ok() + .filter(|s| !s.trim().is_empty()) + .or_else(read_from_env_file) + .expect( + "MAL_CLIENT_ID is not set.\n\ + Set the MAL_CLIENT_ID environment variable (release builds use a \ + GitHub Actions secret), or add a line `MAL_CLIENT_ID=` to \ + a local .env file (which is gitignored).", + ); + + println!("cargo:rustc-env=MAL_CLIENT_ID={}", client_id.trim()); +} + +fn read_from_env_file() -> Option { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?; + let contents = std::fs::read_to_string(Path::new(&manifest_dir).join(".env")).ok()?; + contents.lines().find_map(|line| { + let value = line.trim().strip_prefix("MAL_CLIENT_ID=")?; + let value = value.trim().trim_matches('"').trim_matches('\''); + (!value.is_empty()).then(|| value.to_string()) + }) +} diff --git a/docs/superpowers/specs/2026-06-01-related-timeline-screen-design.md b/docs/superpowers/specs/2026-06-01-related-timeline-screen-design.md new file mode 100644 index 0000000..5c55884 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-related-timeline-screen-design.md @@ -0,0 +1,230 @@ +# Related Timeline Screen — Design + +**Date:** 2026-06-01 +**Status:** Approved (design), pending implementation plan + +## Summary + +Add a new top-level screen, **"Related"**, to the mal-tui app. The user types an +anime title, picks from the search matches, and the screen builds a horizontal +**watch-order timeline**: prequels and sequels laid out left-to-right as the +main line, with other relation types (side story, alternative version, spin-off, +summary, etc.) shown as branches off the selected node. Selecting any node opens +the existing anime detail popup. + +## Goals + +- Let a user explore how an anime relates to its prequels/sequels/side stories. +- Present the prequel→sequel chain as an intuitive left-to-right watch order. +- Reuse existing infrastructure (search, anime store, popup overlay, navbar, + screen caching, image manager) rather than building parallel systems. + +## Non-goals + +- Manga relations (`related_manga`) — out of scope for this screen. +- Editing/managing relations (data is read-only from MAL). +- Re-rooting the timeline by selecting a node (selected nodes open the popup + instead). Could be a future enhancement. + +## User flow + +1. User opens the **Related** screen from the navbar. +2. **Search mode:** user types a title into a search input and submits. +3. **Picking mode:** the screen shows matching anime from MAL search; user + selects one as the timeline root. +4. **Timeline mode:** the screen builds and displays the watch-order timeline + for the chosen anime. +5. Selecting a node (on the line or in a branch) opens the existing anime popup + (full details, synopsis, score, play, add-to-list). + +## Architecture + +### New file + +- `src/screens/related.rs` — `RelatedScreen` struct implementing the `Screen` + trait. +- Registered in the `define_screens!` macro in `src/screens/mod.rs`: + `RELATED => "Related" => related::RelatedScreen`. +- Added to the navbar via `NavBar::add_screen(screens::RELATED)` wherever the + other screens are registered. +- Participates in screen caching via the `add_screen_caching!` macro, matching + the other screens. + +### Reused components + +- `crate::utils::input::Input` — the search text box. +- Shared `anime_store` (keyed by `AnimeId`) from `ExtraInfo` — every fetched + anime (root, chain nodes, branch nodes) is inserted here so the popup overlay + can find it. +- `Action::ShowOverlay(AnimeId)` — emitted on node selection to open the shared + anime popup (handled in `app.rs`). +- Background-thread pattern from `search.rs` / `popup.rs` — a worker thread with + a `Sender` and results delivered back through the app event + channel. +- `ImageManager` — optional small thumbnails on timeline nodes (may be deferred + if it complicates the strip layout; not required for v1). + +### New MAL client method + +`MalClient::get_anime_by_id(&self, id: u64) -> Option` + +- Fetches a single anime with the full field set: `GET {BASE_URL}/anime/{id}?fields=…`. +- The single-anime endpoint returns one anime **object**, not the + `{ data: [...], paging }` list shape that `AnimeResponse` expects. This needs a + fetch/deserialize path that decodes `Anime` directly (a small addition next to + the existing `send_request` / `fetch_anime` plumbing). +- Used to follow the relation chain: each `related_anime` entry only carries + `{ id, title, main_picture }`, so to learn a node's own relations the node must + be re-fetched by id. + +## Screen modes + +A `Mode` enum drives rendering and input: + +- `Search` — typing a query. +- `Picking` — choosing from search results. +- `Timeline` — viewing the built timeline (with a "building" sub-state while the + chain is still being fetched). + +Focus interacts with the navbar following the existing pattern (`NavbarSelect`). + +## Background work + +A worker thread owns a `Receiver`; the screen holds the `Sender`. + +- `LocalEvent::Search(query)`: + - Calls `mal_client.search_anime(query, 0, limit)`. + - Inserts results into the shared store; delivers the list of `AnimeId`s back + to the screen (via the app channel / `BackgroundUpdate`). + - Screen transitions to `Picking`. + +- `LocalEvent::Build(root_id)`: + - Builds the timeline (algorithm below). + - Inserts every fetched main-line anime into the shared store. + - Delivers the assembled `Timeline` structure back to the screen. + - Screen transitions to `Timeline`. + +- `LocalEvent::FetchBranch(id)`: + - Just-in-time `get_anime_by_id(id)` for a selected branch node not yet in the + store; inserts the full anime into the shared store and signals the screen so + it can emit `Action::ShowOverlay(id)`. + +### Timeline-building algorithm + +``` +visited: HashSet +CAP = 25 nodes + +fetch root (already have it from search; ensure full fields) +mark root visited + +# walk backward (prequels) -> prepend to line +node = root +loop: + next = first relation of node where relation_type == "prequel" + if none or next.id in visited or line.len() >= CAP: break + fetch next by id; mark visited; prepend to line; node = next + +# walk forward (sequels) -> append to line +node = root +loop: + next = first relation of node where relation_type == "sequel" + if none or next.id in visited or line.len() >= CAP: break + fetch next by id; mark visited; append to line; node = next + +# branches: for each node on the line, collect its non-prequel/sequel relations +for each line_node: + branches[line_node.id] = [ + (relation_type_formatted, related) # `related` is the related_anime Node + for rel in line_node.related_anime + if rel.relation_type not in {"prequel", "sequel"} + OR rel is an extra prequel/sequel beyond the first (the chosen line) + ] + # branches are NOT fetched or recursed during build. +``` + +Notes: +- Multiple prequels or sequels on one node: the **first** continues the main + line; the rest become branches on that node. +- `visited` prevents infinite loops from bidirectional/cyclic relations. +- `CAP` (25) bounds API calls and screen width; if hit, the UI shows a + "…more not shown" marker. + +### Fetch policy (which nodes get full data, when) + +- **Main-line nodes** are fully fetched during build (required to follow the + chain), so they are in the shared store with complete details. Selecting one + emits `Action::ShowOverlay(id)` directly. +- **Branch nodes** are *not* fetched during build — only the `related_anime` + Node data (`id`, `title`, `main_picture`) is kept, which is enough to display + the branch label and title. This avoids paying for branches the user never + opens. When the user selects a branch, the worker performs a just-in-time + `get_anime_by_id(id)`, inserts the full anime into the shared store, and then + the screen emits `Action::ShowOverlay(id)`. The detail panel therefore shows a + score for main-line nodes and title-only for not-yet-opened branches. + +## Data model + +```rust +struct Timeline { + root: u64, + line: Vec, // chronological: prequel..root..sequel (full anime in store) + branches: HashMap>, // per line-node id -> its branch relations +} + +struct BranchEntry { + relation_label: String, // relation_type_formatted, e.g. "Side story" + id: u64, // related anime id (fetched just-in-time on select) + title: String, // from the related_anime Node (for display) +} +``` + +The screen also tracks: current `Mode`, the search `Input`, the picking result +list, the selected line index, the selected branch index (when focus is in the +branch panel), and a horizontal scroll offset for the strip. + +## Layout (Timeline mode) + +- **Top:** search bar showing the current query (re-searchable). +- **Middle:** horizontal, scrollable strip of node boxes joined by `──` + connectors. The selected node is highlighted; `◀`/`▶` indicate off-screen + nodes. Each box shows the (truncated) title and score. +- **Bottom:** a detail panel for the currently selected node — title, type, + episodes, status, score — plus a list of that node's branch relations, each + selectable. + +**Picking mode** reuses the top search bar and shows a simple selectable list of +matches (title + year/type/episodes). + +### Input + +- `←` / `→` — move selection along the main line (scrolls the strip). +- `↑` / `↓` — move focus into / out of the branch list of the selected node. +- `Enter` — open the popup for the selected node or branch entry + (`Action::ShowOverlay(id)`). +- Search bar focus follows the existing input + navbar conventions. + +## Error handling & edge cases + +- **No search results:** show an inline message; stay in Search/Picking. +- **Anime with no relations:** timeline is a single node (just the root). +- **Cycles / bidirectional links:** guarded by the `visited` set. +- **Cap reached:** show a "…more not shown" marker at the truncated end. +- **Fetch failure mid-build:** keep the partial timeline already assembled and + surface a soft error (e.g. via `Action::ShowError` or an inline notice) rather + than discarding everything. +- **Building state:** while the chain is still fetching, show a "Building…" + indicator; the strip can fill in progressively or appear once complete + (progressive is preferred if cheap, otherwise deliver once complete). + +## Testing + +- Unit-test the timeline-building algorithm with synthetic `Anime` graphs: + - linear prequel/sequel chain → correct ordered `line`. + - branching (multiple sequels) → first on line, extras in `branches`. + - cyclic relations → terminates, no duplicates. + - cap enforcement → stops at `CAP`, marks truncation. + - non-sequel/prequel relations → land in `branches`, not the line. +- Manual/integration: run the app, search a multi-season franchise (e.g. Attack + on Titan), verify the ordered line, branch listing, scrolling, and that + selecting nodes opens the popup with correct details. diff --git a/src/app.rs b/src/app.rs index 2fd46d4..91ab5f9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -40,6 +40,7 @@ pub enum Action { PlayEpisode(AnimeId, u32), SwitchScreen(&'static str), ShowOverlay(AnimeId), + ShowRelated(AnimeId), NavbarSelect(bool), ShowError(String), SyncAnimes(Vec<(bool, Anime)>), @@ -324,6 +325,9 @@ impl App { Action::ShowOverlay(anime_id) => { self.screen_manager.toggle_overlay(anime_id); } + Action::ShowRelated(anime_id) => { + self.screen_manager.show_related(anime_id); + } Action::NavbarSelect(selected) => { self.screen_manager.toggle_navbar(selected); } diff --git a/src/config/README.md b/src/config/README.md index 9baa165..56bd44d 100644 --- a/src/config/README.md +++ b/src/config/README.md @@ -11,6 +11,8 @@ To reset to default settings, simply delete the config file. mal-tui will automa ```toml allow_nsfw = false +# show titles in romaji (MAL's main title) instead of English +prefer_romaji = false [navigation] nav_up = ["Up", { Char = "k" }] diff --git a/src/config/mod.rs b/src/config/mod.rs index 5d32f03..5ebadf3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -31,6 +31,9 @@ pub struct Config { pub theme: Theme, pub allow_nsfw: Option, + + /// Show titles in romaji (MAL's main title) instead of English. + pub prefer_romaji: Option, } impl Config { @@ -51,9 +54,15 @@ impl Config { player: Player::default(), theme: Theme::default(), allow_nsfw: Some(true), + prefer_romaji: Some(false), } } + /// Whether titles should be shown in romaji instead of English. + pub fn prefer_romaji(&self) -> bool { + self.prefer_romaji.unwrap_or(false) + } + // where configs are stored (default) pub fn config_dir() -> PathBuf { diff --git a/src/mal/mod.rs b/src/mal/mod.rs index 71d14a8..9d6ba08 100644 --- a/src/mal/mod.rs +++ b/src/mal/mod.rs @@ -281,6 +281,46 @@ impl MalClient { ) } + // Fetch a single anime by id with the full field set. Unlike the list + // endpoints this returns one anime object, so it goes through + // `fetch_single_anime` rather than the `Fetchable`/list path. + pub fn get_anime_by_id(&self, id: u64) -> Option { + let identity = self.identity.read().unwrap(); + let identifier = match identity.as_ref() { + Some(token) => Identifier::new(Some(token.access_token.clone()), None), + None => Identifier::new(None, self.get_client_id()), + }; + + // The timeline walks many anime sequentially, so keep each response + // small: drop the bulkiest fields it never shows (recommendations embeds + // whole anime objects; pictures is a gallery; statistics/background are + // unused here). Everything the boxes and the popup read is kept. + let heavy = [ + fields::STATISTICS, + fields::RECOMMENDATIONS, + fields::PICTURES, + fields::BACKGROUND, + ]; + let trimmed_fields = fields::ALL + .iter() + .filter(|f| !heavy.contains(f)) + .copied() + .collect::>() + .join(","); + + match network::fetch_single_anime( + identifier, + format!("{}/anime/{}", BASE_URL, id), + params!["fields" => trimmed_fields], + ) { + Ok(anime) => Some(anime), + Err(e) => { + send_error!("Error fetching anime {}: {:?}", id, e); + None + } + } + } + pub fn get_user(&self) -> Option { self.send_request::( format!("{}/users/@me", BASE_URL), diff --git a/src/mal/models/anime.rs b/src/mal/models/anime.rs index becdaaf..8c987e4 100644 --- a/src/mal/models/anime.rs +++ b/src/mal/models/anime.rs @@ -73,6 +73,25 @@ where Ok(watch_status_from_api(s)) } +// MAL's `statistics` object returns its status counts as JSON strings +// (e.g. "watching": "238321") even though they are integers. Accept either a +// string or a number so single-anime detail responses deserialize cleanly. +fn de_u64_flexible<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StrOrNum { + Str(String), + Num(u64), + } + match StrOrNum::deserialize(deserializer)? { + StrOrNum::Str(s) => s.trim().parse::().map_err(serde::de::Error::custom), + StrOrNum::Num(n) => Ok(n), + } +} + fn default_true() -> bool { true } @@ -448,6 +467,29 @@ impl Anime { .collect::>() .join(", ") } + + /// The title to display, honoring the user's `prefer_romaji` config. + /// + /// MAL's main `title` field is the romaji title; `alternative_titles.en` + /// is the English title (sent as `""`, or absent → "N/A"). When romaji is + /// preferred we use `title`; otherwise we prefer English and fall back to + /// romaji when no English title is available. + pub fn display_title(&self) -> String { + let en = &self.alternative_titles.en; + let en_available = !en.is_empty() && en != "N/A"; + + if crate::config::Config::global().prefer_romaji() { + if !self.title.is_empty() { + self.title.clone() + } else { + en.clone() + } + } else if en_available { + en.clone() + } else { + self.title.clone() + } + } } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -592,16 +634,22 @@ pub struct Recommendation { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Status { + #[serde(deserialize_with = "de_u64_flexible", default)] pub watching: u64, + #[serde(deserialize_with = "de_u64_flexible", default)] pub completed: u64, + #[serde(deserialize_with = "de_u64_flexible", default)] pub on_hold: u64, + #[serde(deserialize_with = "de_u64_flexible", default)] pub dropped: u64, + #[serde(deserialize_with = "de_u64_flexible", default)] pub plan_to_watch: u64, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Statistics { pub status: Status, + #[serde(deserialize_with = "de_u64_flexible", default)] pub num_list_users: u64, } impl Default for Statistics { diff --git a/src/mal/network.rs b/src/mal/network.rs index ea2cbc4..0684ca5 100644 --- a/src/mal/network.rs +++ b/src/mal/network.rs @@ -1,6 +1,6 @@ use crate::send_error; -use super::models::anime::{AnimeResponse, FavoriteAnime, FavoriteResponse, JikanData}; +use super::models::anime::{Anime, AnimeResponse, FavoriteAnime, FavoriteResponse, JikanData}; use regex::Regex; use std::collections::HashSet; use std::sync::LazyLock; @@ -117,6 +117,24 @@ pub fn fetch_anime( ) } +// The single-anime endpoint (`/anime/{id}`) returns one anime object directly, +// not the `{ data: [...], paging }` list shape that `AnimeResponse` decodes, so +// it gets its own fetch that deserializes `Anime` on its own. +#[cached(size = 2000, result = true)] +pub fn fetch_single_anime( + identifier: Identifier, + url: String, + parameters: Vec<(String, String)>, +) -> Result> { + send_request::( + "GET", // + url, + parameters, + identifier.to_headers(), + None, + ) +} + #[cached(result = true)] pub fn fetch_user( identifier: Identifier, diff --git a/src/mal/oauth.rs b/src/mal/oauth.rs index 14e2d79..29c6b0b 100644 --- a/src/mal/oauth.rs +++ b/src/mal/oauth.rs @@ -14,7 +14,9 @@ use crate::send_error; const AUTHORIZE: &str = "https://myanimelist.net/v1/oauth2/authorize"; const TOKEN: &str = "https://myanimelist.net/v1/oauth2/token"; -pub const CLIENT_ID: &str = "0b58e985aa74283d56529f193e9b1e3f"; +// Injected at build time by build.rs (from the MAL_CLIENT_ID env var or a +// gitignored .env file) so it is not hard-coded in the source. +pub const CLIENT_ID: &str = env!("MAL_CLIENT_ID"); #[derive(Debug, Clone, Deserialize)] diff --git a/src/screens/mod.rs b/src/screens/mod.rs index 8a705a4..e4b83aa 100644 --- a/src/screens/mod.rs +++ b/src/screens/mod.rs @@ -61,6 +61,7 @@ mod search; mod login; mod info; mod list; +mod related; // this is a macro to define screens in a more structured way // it allows for screens to be implemented in a single place and work across the app @@ -156,6 +157,7 @@ define_screens! { SEASONS => "Seasons" => seasons::SeasonsScreen, SEARCH => "Search" => search::SearchScreen, LIST => "List" => list::ListScreen, + RELATED => "Related" => related::RelatedScreen, // To add more:: // SCREEN1 => "" => ::Screen, @@ -200,6 +202,10 @@ pub trait Screen { None } fn apply_update(&mut self, update: BackgroundUpdate) {} + + // hook for screens that can be opened pre-loaded with a specific anime + // (the Related screen builds its timeline from it). Default: ignore. + fn build_related(&mut self, _anime: AnimeId) {} } pub struct ScreenManager { @@ -224,6 +230,7 @@ impl ScreenManager { .add_screen(SEASONS) .add_screen(SEARCH) .add_screen(LIST) + .add_screen(RELATED) .add_screen(PROFILE), overlay: popup::AnimePopup::new(passable_info.clone()), error_overlay: popup::ErrorPopup::new(), @@ -274,6 +281,14 @@ impl ScreenManager { self.overlay.open(); } + // switch to the Related screen and immediately build its timeline from the + // given anime (used by the "Related series" button in the anime popup) + pub fn show_related(&mut self, anime: AnimeId) { + self.overlay.close(); + self.change_screen(RELATED); + self.current_screen.build_related(anime); + } + pub fn refresh(&mut self) { self.overlay.update_buttons(); } diff --git a/src/screens/related.rs b/src/screens/related.rs new file mode 100644 index 0000000..94c3611 --- /dev/null +++ b/src/screens/related.rs @@ -0,0 +1,1038 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::mpsc::{Sender, channel}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Position, Rect}; +use ratatui::style::Style; +use ratatui::symbols::border; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap}; + +use crate::add_screen_caching; +use crate::app::{Action, Event}; +use crate::config::Config; +use crate::config::navigation::NavDirection; +use crate::mal::models::anime::{Anime, AnimeId}; +use crate::utils::imageManager::ImageManager; +use crate::utils::input::Input; + +use super::widgets::animebox::AnimeBox; +use crate::screens::Screen; + +use super::{BackgroundUpdate, ExtraInfo}; + +// Cap how far the prequel/sequel chain is followed, and a higher overall cap +// including branch nodes that get pre-fetched so selecting them opens the popup. +const LINE_CAP: usize = 25; +const TOTAL_FETCH_CAP: usize = 45; + +// Relation types that form the horizontal "watch order" line. +const PREQUEL: &str = "prequel"; +const SEQUEL: &str = "sequel"; + +#[derive(Clone, PartialEq)] +enum Mode { + Search, + Picking, + Timeline, +} + +#[derive(Clone, PartialEq)] +enum Focus { + NavBar, + Search, + Results, + Timeline, +} + +#[derive(Clone)] +struct BranchEntry { + relation_label: String, + id: AnimeId, + title: String, +} + +#[derive(Clone)] +struct Timeline { + line: Vec, + root_index: usize, + branches: HashMap>, +} + +#[derive(Clone)] +enum LocalEvent { + Search(String), + Build(AnimeId), +} + +#[derive(Clone)] +pub struct RelatedScreen { + app_info: ExtraInfo, + image_manager: Arc>, + + mode: Mode, + focus: Focus, + status: Option, + busy: bool, + + search_input: Input, + search_area: Option, + + results: Vec, + pick_index: usize, + pick_areas: Vec<(usize, Rect)>, + + timeline: Option, + line_index: usize, + in_branches: bool, + branch_index: usize, + scroll: usize, + node_areas: Vec<(usize, Rect)>, + branch_areas: Vec<(usize, Rect)>, + + bg_sender: Option>, + bg_loaded: bool, +} + +impl RelatedScreen { + pub fn new(info: ExtraInfo) -> Self { + Self { + app_info: info, + image_manager: Arc::new(Mutex::new(ImageManager::new())), + mode: Mode::Search, + focus: Focus::Search, + status: None, + busy: false, + search_input: Input::new(), + search_area: None, + results: Vec::new(), + pick_index: 0, + pick_areas: Vec::new(), + timeline: None, + line_index: 0, + in_branches: false, + branch_index: 0, + scroll: 0, + node_areas: Vec::new(), + branch_areas: Vec::new(), + bg_sender: None, + bg_loaded: false, + } + } + + fn send(&self, event: LocalEvent) { + if let Some(sender) = &self.bg_sender { + sender.send(event).ok(); + } + } + + // switch to the timeline page immediately (showing just the searched anime + // plus a loading placeholder) and kick off the background chain fetch + fn start_build(&mut self, root_id: AnimeId) { + self.busy = true; + self.status = None; + self.mode = Mode::Timeline; + self.focus = Focus::Timeline; + self.line_index = 0; + self.scroll = 0; + self.in_branches = false; + self.branch_index = 0; + self.timeline = Some(Timeline { + line: vec![root_id], + root_index: 0, + branches: HashMap::new(), + }); + self.send(LocalEvent::Build(root_id)); + } + + // move focus from the search box into whatever content is showing + fn leave_search(&mut self) { + if self.mode == Mode::Picking && !self.results.is_empty() { + self.focus = Focus::Results; + } else if self.mode == Mode::Timeline { + self.focus = Focus::Timeline; + } + } + + // current branch list for the selected line node, if any + fn current_branches(&self) -> &[BranchEntry] { + self.timeline + .as_ref() + .and_then(|t| t.line.get(self.line_index).and_then(|id| t.branches.get(id))) + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } +} + +// Walk the relation graph outward from `root` to build the watch-order line and +// collect side relations as branches. `fetch` resolves an anime id to a fully +// populated Anime (it carries its own `related_anime`). Pure over `fetch` so it +// can be unit-tested with a synthetic graph. +fn build_timeline( + root: Anime, + mut fetch: F, + mut emit: E, + mut emit_branch: B, +) -> (Vec, Vec, usize, HashMap>) +where + F: FnMut(AnimeId) -> Option, + // called as each line node is fetched: (new anime, current ordered line, root index) + E: FnMut(&Anime, Vec, usize), + // called as each branch is resolved: (parent line-node id, entry, fetched anime if any) + B: FnMut(AnimeId, &BranchEntry, Option<&Anime>), +{ + let root_id = root.id; + let mut visited: HashSet = HashSet::new(); + visited.insert(root_id); + + let mut animes: Vec = Vec::new(); + + // walk backward along prequels, emitting each as it arrives + let mut back: Vec = Vec::new(); + let mut node = root.clone(); + while back.len() + 1 < LINE_CAP { + let Some(next_id) = first_relation(&node, PREQUEL, &visited) else { break }; + let Some(fetched) = fetch(next_id) else { break }; + visited.insert(fetched.id); + node = fetched.clone(); + back.push(fetched.clone()); + + let mut line: Vec = back.iter().rev().map(|a| a.id).collect(); + line.push(root_id); + emit(&fetched, line, back.len()); + } + + // walk forward along sequels, emitting each as it arrives + let mut forward: Vec = Vec::new(); + let mut node = root.clone(); + while back.len() + forward.len() + 1 < LINE_CAP { + let Some(next_id) = first_relation(&node, SEQUEL, &visited) else { break }; + let Some(fetched) = fetch(next_id) else { break }; + visited.insert(fetched.id); + node = fetched.clone(); + forward.push(fetched.clone()); + + let mut line: Vec = back.iter().rev().map(|a| a.id).collect(); + line.push(root_id); + line.extend(forward.iter().map(|a| a.id)); + emit(&fetched, line, back.len()); + } + + // assemble the ordered line: reversed prequels .. root .. sequels + let root_index = back.len(); + let mut line_animes: Vec = Vec::with_capacity(back.len() + 1 + forward.len()); + line_animes.extend(back.into_iter().rev()); + line_animes.push(root); + line_animes.extend(forward); + + let line: Vec = line_animes.iter().map(|a| a.id).collect(); + let line_ids: HashSet = line.iter().copied().collect(); + + // branches: every related anime of a line node that isn't itself on the line. + // Each branch's anime is fetched (so selecting it can open the popup) and + // emitted one at a time so side stories pop in as they arrive, just like the + // main line. A failed/skipped fetch still emits the entry (title only). + let mut branches: HashMap> = HashMap::new(); + for a in &line_animes { + for rel in a.related_anime.iter().flatten() { + let rid = rel.node.id as AnimeId; + if line_ids.contains(&rid) { + continue; + } + let entry = BranchEntry { + relation_label: rel.relation_type_formatted.clone(), + id: rid, + title: rel.node.title.clone(), + }; + + let fetched = if !visited.contains(&rid) + && line_animes.len() + animes.len() < TOTAL_FETCH_CAP + { + fetch(rid) + } else { + None + }; + if let Some(f) = &fetched { + visited.insert(f.id); + } + + emit_branch(a.id, &entry, fetched.as_ref()); + + if let Some(f) = fetched { + animes.push(f); + } + branches.entry(a.id).or_default().push(entry); + } + } + + animes.extend(line_animes); + + (animes, line, root_index, branches) +} + +fn first_relation(anime: &Anime, rel_type: &str, visited: &HashSet) -> Option { + anime + .related_anime + .iter() + .flatten() + .find(|r| r.relation_type == rel_type && !visited.contains(&(r.node.id as AnimeId))) + .map(|r| r.node.id as AnimeId) +} + +impl Screen for RelatedScreen { + add_screen_caching!(); + + // entry point used by the "Related series" popup button: jump straight to a + // timeline built from the given anime, skipping search/picking + fn build_related(&mut self, anime: AnimeId) { + self.results.clear(); + self.start_build(anime); + } + + fn background(&mut self) -> Option> { + if self.bg_loaded { + return None; + } + self.bg_loaded = true; + + let (tx, rx) = channel::(); + self.bg_sender = Some(tx); + let id = self.get_name(); + let mal_client = self.app_info.mal_client.clone(); + let app_sx = self.app_info.app_sx.clone(); + ImageManager::init_with_threads(&self.image_manager, app_sx.clone()); + + Some(std::thread::spawn(move || { + while let Ok(event) = rx.recv() { + match event { + LocalEvent::Search(query) => { + let results = mal_client.search_anime(query, 0, 30).unwrap_or_default(); + let ids: Vec = results.iter().map(|a| a.id).collect(); + let update = BackgroundUpdate::new(id.clone()) + .set("animes", results) + .set("search_results", ids); + app_sx.send(Event::BackgroundNotice(update)).ok(); + } + LocalEvent::Build(root_id) => { + match mal_client.get_anime_by_id(root_id as u64) { + Some(root) => { + // stream each node to the screen as it's fetched + let emit = |anime: &Anime, line: Vec, root_index: usize| { + let update = BackgroundUpdate::new(id.clone()) + .set("animes", vec![anime.clone()]) + .set("timeline_line", line) + .set("timeline_root", root_index) + .set("building", true); + app_sx.send(Event::BackgroundNotice(update)).ok(); + }; + let emit_branch = + |parent: AnimeId, entry: &BranchEntry, anime: Option<&Anime>| { + let mut update = BackgroundUpdate::new(id.clone()) + .set("branch_for", parent) + .set("branch_entry", entry.clone()) + .set("building", true); + if let Some(a) = anime { + update = update.set("animes", vec![a.clone()]); + } + app_sx.send(Event::BackgroundNotice(update)).ok(); + }; + let (animes, line, root_index, branches) = build_timeline( + root, + |id| mal_client.get_anime_by_id(id as u64), + emit, + emit_branch, + ); + // final update: full line + branches, building done + let update = BackgroundUpdate::new(id.clone()) + .set("animes", animes) + .set("timeline_line", line) + .set("timeline_root", root_index) + .set("timeline_branches", branches) + .set("building", false); + app_sx.send(Event::BackgroundNotice(update)).ok(); + } + None => { + let update = BackgroundUpdate::new(id.clone()) + .set("build_failed", true); + app_sx.send(Event::BackgroundNotice(update)).ok(); + } + } + } + } + } + })) + } + + fn apply_update(&mut self, mut update: BackgroundUpdate) { + if let Some(results) = update.take::>("search_results") { + self.busy = false; + self.results = results; + self.pick_index = 0; + if self.results.is_empty() { + self.status = Some("No results found".to_string()); + self.mode = Mode::Search; + self.focus = Focus::Search; + } else { + self.status = None; + self.mode = Mode::Picking; + self.focus = Focus::Results; + } + } + + if let Some(line) = update.take::>("timeline_line") { + let root_index = update.take::("timeline_root").unwrap_or(0); + let building = update.take::("building").unwrap_or(false); + let branches = update.take::>>("timeline_branches"); + + // merge the incremental update into the (already shown) timeline + let timeline = self.timeline.get_or_insert_with(|| Timeline { + line: Vec::new(), + root_index: 0, + branches: HashMap::new(), + }); + timeline.line = line; + timeline.root_index = root_index; + if let Some(branches) = branches { + timeline.branches = branches; + } + self.busy = building; + self.status = None; + self.mode = Mode::Timeline; + // keep the highlight on the searched anime as boxes stream in + self.line_index = root_index; + } + + // a single branch (side story etc.) streamed in for a line node + if let Some(parent) = update.take::("branch_for") + && let Some(entry) = update.take::("branch_entry") + && let Some(timeline) = self.timeline.as_mut() + { + timeline.branches.entry(parent).or_default().push(entry); + } + + if update.take::("build_failed").is_some() { + // keep whatever we already show (the searched anime) and just stop + // the loading indicator rather than bouncing back to the list + self.busy = false; + } + } + + fn draw(&mut self, frame: &mut Frame) { + let area = frame.area(); + frame.render_widget(Clear, area); + + // leave room for the navbar at the top (rendered by the manager) + let [_navbar, body] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Fill(1)]) + .areas(area); + + let [search_area, content] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Fill(1)]) + .areas(body); + + self.draw_search_bar(frame, search_area); + + match self.mode { + Mode::Search => self.draw_hint(frame, content), + Mode::Picking => self.draw_picking(frame, content), + Mode::Timeline => self.draw_timeline(frame, content), + } + } + + fn handle_keyboard(&mut self, key_event: crossterm::event::KeyEvent) -> Option { + let nav = &Config::global().navigation; + let modifier = key_event + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL); + + // ctrl+up steps focus up one level (timeline/results -> search -> navbar) + // so a plain up never yanks you out of the content unexpectedly + if modifier && nav.get_direction(&key_event.code) == NavDirection::Up { + match self.focus { + Focus::Timeline => { + self.in_branches = false; + self.focus = Focus::Search; + } + Focus::Results => self.focus = Focus::Search, + _ => { + self.focus = Focus::NavBar; + return Some(Action::NavbarSelect(true)); + } + } + return None; + } + + match self.focus { + Focus::NavBar => { + self.focus = Focus::Search; + None + } + Focus::Search => { + // Ctrl + down moves out of the search box; without the modifier + // the key (e.g. "j") is typed into the query instead. + if modifier && nav.get_direction(&key_event.code) == NavDirection::Down { + self.leave_search(); + } else if let Some(query) = self.search_input.handle_event(key_event, false) { + self.busy = true; + self.status = Some("Searching…".to_string()); + self.send(LocalEvent::Search(query)); + } + None + } + Focus::Results => self.handle_picking_keys(nav, &key_event), + Focus::Timeline => self.handle_timeline_keys(nav, &key_event), + } + } + + fn handle_mouse(&mut self, mouse_event: crossterm::event::MouseEvent) -> Option { + if mouse_event.row < 3 { + self.focus = Focus::NavBar; + return Some(Action::NavbarSelect(true)); + } + let pos = Position::new(mouse_event.column, mouse_event.row); + + // hovering / clicking a search result highlights it (and click builds it) + if self.mode == Mode::Picking { + if let Some((idx, _)) = self.pick_areas.iter().find(|(_, r)| r.contains(pos)) { + let idx = *idx; + self.focus = Focus::Results; + self.pick_index = idx; + if let crossterm::event::MouseEventKind::Down(_) = mouse_event.kind + && let Some(root_id) = self.results.get(idx).copied() + { + self.start_build(root_id); + } + return None; + } + } + + // hovering / clicking a timeline box highlights it (and click opens it) + if self.mode == Mode::Timeline { + if let Some((node_idx, _)) = self.node_areas.iter().find(|(_, r)| r.contains(pos)) { + let node_idx = *node_idx; + self.focus = Focus::Timeline; + self.in_branches = false; + self.line_index = node_idx; + if let crossterm::event::MouseEventKind::Down(_) = mouse_event.kind + && let Some(id) = self.timeline.as_ref().and_then(|t| t.line.get(node_idx).copied()) + && self.app_info.anime_store.get(&id).is_some() + { + return Some(Action::ShowOverlay(id)); + } + return None; + } + + // hovering / clicking a related (branch) entry highlights and opens it + if let Some((bi, _)) = self.branch_areas.iter().find(|(_, r)| r.contains(pos)) { + let bi = *bi; + self.focus = Focus::Timeline; + self.in_branches = true; + self.branch_index = bi; + if let crossterm::event::MouseEventKind::Down(_) = mouse_event.kind + && let Some(id) = self.current_branches().get(bi).map(|b| b.id) + && self.app_info.anime_store.get(&id).is_some() + { + return Some(Action::ShowOverlay(id)); + } + return None; + } + } + + if let Some(search_area) = self.search_area { + if search_area.contains(pos) { + self.focus = Focus::Search; + } else if mouse_event.row >= search_area.y + search_area.height { + // clicking anywhere below the search box moves focus into content + self.leave_search(); + } + } + None + } +} + +impl RelatedScreen { + fn handle_picking_keys( + &mut self, + nav: &crate::config::navigation::Navigation, + key_event: &crossterm::event::KeyEvent, + ) -> Option { + match nav.get_direction(&key_event.code) { + NavDirection::Up => { + if self.pick_index == 0 { + self.focus = Focus::Search; + } else { + self.pick_index -= 1; + } + } + NavDirection::Down => { + if self.pick_index + 1 < self.results.len() { + self.pick_index += 1; + } + } + _ => {} + } + + if nav.is_select(&key_event.code) + && let Some(root_id) = self.results.get(self.pick_index).copied() + { + self.start_build(root_id); + } + None + } + + fn handle_timeline_keys( + &mut self, + nav: &crate::config::navigation::Navigation, + key_event: &crossterm::event::KeyEvent, + ) -> Option { + let line_len = self.timeline.as_ref().map(|t| t.line.len()).unwrap_or(0); + if line_len == 0 { + return None; + } + + match nav.get_direction(&key_event.code) { + NavDirection::Left => { + self.in_branches = false; + if self.line_index > 0 { + self.line_index -= 1; + } + } + NavDirection::Right => { + self.in_branches = false; + if self.line_index + 1 < line_len { + self.line_index += 1; + } + } + NavDirection::Down => { + let branch_len = self.current_branches().len(); + if !self.in_branches && branch_len > 0 { + self.in_branches = true; + self.branch_index = 0; + } else if self.in_branches && self.branch_index + 1 < branch_len { + self.branch_index += 1; + } + } + NavDirection::Up => { + // plain up only navigates within the branch list; leaving the + // timeline upward is reserved for ctrl+up + if self.in_branches { + if self.branch_index == 0 { + self.in_branches = false; + } else { + self.branch_index -= 1; + } + } + } + NavDirection::None => {} + } + + if nav.is_select(&key_event.code) { + let target = if self.in_branches { + self.current_branches().get(self.branch_index).map(|b| b.id) + } else { + self.timeline.as_ref().and_then(|t| t.line.get(self.line_index).copied()) + }; + if let Some(id) = target + && self.app_info.anime_store.get(&id).is_some() + { + return Some(Action::ShowOverlay(id)); + } + } + None + } + + fn draw_search_bar(&mut self, frame: &mut Frame, area: Rect) { + let focused = self.focus == Focus::Search; + let field = Paragraph::new(self.search_input.value()) + .block( + Block::default() + .borders(Borders::ALL) + .title("Related — search an anime") + .border_set(border::ROUNDED), + ) + .style(Style::default().fg(if focused { + Config::global().theme.highlight + } else { + Config::global().theme.primary + })); + frame.render_widget(field, area); + self.search_area = Some(area); + self.search_input + .render_cursor(frame, area.x + 1, area.y + 1, focused); + } + + fn draw_hint(&self, frame: &mut Frame, area: Rect) { + let msg = self + .status + .clone() + .unwrap_or_else(|| "Type an anime title and press Enter to see its timeline.".to_string()); + let p = Paragraph::new(msg) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(Config::global().theme.text)) + .block(Block::default().padding(Padding::new(2, 2, 2, 2))); + frame.render_widget(p, area); + } + + fn draw_picking(&mut self, frame: &mut Frame, area: Rect) { + self.pick_areas.clear(); + let theme = &Config::global().theme; + + // header line, then the bordered rows below it + let [header_area, list_area] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Fill(1)]) + .areas(area.inner(ratatui::layout::Margin::new(2, 1))); + frame.render_widget( + Paragraph::new("Pick the anime to build the timeline from:") + .style(Style::default().fg(theme.text)), + header_area, + ); + + const ROW_H: u16 = 6; + const GAP: u16 = 1; + let slot = ROW_H + GAP; + let visible = (list_area.height / slot).max(1) as usize; + let start = if self.pick_index >= visible { + self.pick_index + 1 - visible + } else { + 0 + }; + + for (offset, i) in (start..self.results.len()).enumerate() { + if offset >= visible { + break; + } + let id = self.results[i]; + let Some(anime) = self.app_info.anime_store.get(&id) else { continue }; + let row = Rect::new( + list_area.x, + list_area.y + offset as u16 * slot, + list_area.width, + ROW_H, + ); + + let selected = i == self.pick_index && self.focus == Focus::Results; + let border_color = if selected { theme.highlight } else { theme.primary }; + let block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(border_color)); + let inner = block.inner(row); + frame.render_widget(block, row); + + // thumbnail on the left, text on the right + let [thumb_area, text_area] = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(8), Constraint::Fill(1)]) + .areas(inner); + ImageManager::render_image(&self.image_manager, &*anime, frame, thumb_area, true); + + let text = Text::from(vec![ + Line::from(Span::styled( + anime.display_title(), + Style::default().fg(if selected { theme.highlight } else { theme.secondary }), + )), + Line::from(Span::styled( + format!("{} · {} ep", anime.media_type, anime.num_episodes), + Style::default().fg(theme.text), + )), + ]); + frame.render_widget( + Paragraph::new(text) + .wrap(Wrap { trim: true }) + .block(Block::default().padding(Padding::new(1, 1, 1, 0))), + text_area, + ); + + self.pick_areas.push((i, row)); + } + } + + fn draw_timeline(&mut self, frame: &mut Frame, area: Rect) { + let Some(timeline) = self.timeline.clone() else { return }; + + // the strip is a fixed, compact height (label + box + a little breathing + // room); the branch panel sits right under it and the rest stays empty + const STRIP_H: u16 = 15; + let branch_count = self.current_branches().len() as u16; + let branch_h = if branch_count == 0 { + 3 + } else { + (branch_count + 4).min(area.height / 2) + }; + let [strip_area, branch_area, _rest] = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(STRIP_H.min(area.height)), + Constraint::Length(branch_h), + Constraint::Fill(1), + ]) + .areas(area); + + self.draw_strip(frame, strip_area, &timeline); + self.draw_branches(frame, branch_area, &Config::global().theme); + } + + // horizontal, scrollable strip of AnimeBoxes, each captioned with its + // relation to the searched anime (Prequel / Searched / Sequel) + fn draw_strip(&mut self, frame: &mut Frame, area: Rect, timeline: &Timeline) { + let theme = &Config::global().theme; + self.node_areas.clear(); + const NODE_W: u16 = 34; + const GAP: u16 = 2; + const BOX_H: u16 = 12; + let slot = NODE_W + GAP; + let visible = ((area.width.saturating_sub(2)) / slot).max(1) as usize; + + // a label row + a fixed-height box, vertically centered in the strip + let cell_h = BOX_H + 1; + let cell_y = area.y + area.height.saturating_sub(cell_h) / 2; + + // keep the selected node within the visible window + let mut scroll = self.scroll; + if self.line_index < scroll { + scroll = self.line_index; + } else if self.line_index >= scroll + visible { + scroll = self.line_index + 1 - visible; + } + let end = (scroll + visible).min(timeline.line.len()); + + for (slot_idx, node_idx) in (scroll..end).enumerate() { + let x = area.x + 1 + slot_idx as u16 * slot; + if x + NODE_W > area.x + area.width { + break; + } + let is_sel = self.focus == Focus::Timeline + && node_idx == self.line_index + && !self.in_branches; + + let label_area = Rect::new(x, cell_y, NODE_W, 1); + let box_area = Rect::new(x, cell_y + 1, NODE_W, BOX_H); + + // relation caption — lights up with the box when selected + let label = if node_idx == timeline.root_index { + "● Searched" + } else if node_idx < timeline.root_index { + "Prequel" + } else { + "Sequel" + }; + let label_color = if is_sel { + theme.highlight + } else if node_idx == timeline.root_index { + theme.second_highlight + } else { + theme.primary + }; + frame.render_widget( + Paragraph::new(label) + .alignment(Alignment::Center) + .style(Style::default().fg(label_color)), + label_area, + ); + + // the anime box itself (highlighted when this node is selected) + if let Some(anime) = self.app_info.anime_store.get(&timeline.line[node_idx]) { + AnimeBox::render(&anime, &self.image_manager, frame, box_area, is_sel); + } + self.node_areas.push((node_idx, box_area)); + } + + // while the chain is still fetching, show a "Loading…" placeholder box + // in the next slot so it's clear more is on the way + if self.busy { + let slot_idx = (end - scroll) as u16; + let x = area.x + 1 + slot_idx * slot; + if x + NODE_W <= area.x + area.width { + frame.render_widget( + Paragraph::new("…") + .alignment(Alignment::Center) + .style(Style::default().fg(theme.primary)), + Rect::new(x, cell_y, NODE_W, 1), + ); + let box_area = Rect::new(x, cell_y + 1, NODE_W, BOX_H); + let block = Block::default() + .borders(Borders::ALL) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(theme.primary)); + let inner = block.inner(box_area); + frame.render_widget(block, box_area); + let mid = Rect::new(inner.x, inner.y + inner.height / 2, inner.width, 1); + frame.render_widget( + Paragraph::new("Loading…") + .alignment(Alignment::Center) + .style(Style::default().fg(theme.primary)), + mid, + ); + } + } + + // overflow markers + let my = cell_y + cell_h / 2; + if scroll > 0 { + frame.render_widget( + Paragraph::new("◀").style(Style::default().fg(theme.primary)), + Rect::new(area.x, my, 1, 1), + ); + } + if end < timeline.line.len() { + frame.render_widget( + Paragraph::new("▶").style(Style::default().fg(theme.primary)), + Rect::new(area.x + area.width - 1, my, 1, 1), + ); + } + } + + fn draw_branches(&mut self, frame: &mut Frame, area: Rect, theme: &crate::config::theme::Theme) { + self.branch_areas.clear(); + let mut lines: Vec = Vec::new(); + let branches = self.current_branches(); + let count = branches.len(); + if branches.is_empty() { + lines.push(Line::from(Span::styled( + "←/→ move · Enter opens · ctrl+↑ search", + Style::default().fg(theme.primary), + ))); + } else { + lines.push(Line::from(Span::styled( + "Related:", + Style::default().fg(theme.text), + ))); + for (i, b) in branches.iter().enumerate() { + let selected = + self.focus == Focus::Timeline && self.in_branches && i == self.branch_index; + let marker = if selected { "▸ " } else { " " }; + let style = if selected { + Style::default().fg(theme.highlight) + } else { + Style::default().fg(theme.text) + }; + lines.push(Line::from(Span::styled( + format!("{}{:<12} ▸ {}", marker, b.relation_label, b.title), + style, + ))); + } + lines.push(Line::from(Span::styled( + "←/→ move · ↑/↓ branches · Enter opens · ctrl+↑ search", + Style::default().fg(theme.primary), + ))); + } + + let panel = Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: true }) + .block( + Block::default() + .borders(Borders::TOP) + .border_set(border::PLAIN) + .padding(Padding::new(2, 2, 0, 0)), + ); + frame.render_widget(panel, area); + + // record clickable rows: top border occupies row 0, "Related:" header + // row 1, so branch entry `i` sits at row offset 2 + i. + for i in 0..count { + let row = area.y + 2 + i as u16; + if row >= area.y + area.height { + break; + } + self.branch_areas + .push((i, Rect::new(area.x + 1, row, area.width.saturating_sub(2), 1))); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mal::models::anime::{Anime, Node, RelatedAnime}; + + fn rel(id: u64, kind: &str) -> RelatedAnime { + RelatedAnime { + node: Node { + id, + title: format!("Anime {}", id), + main_picture: None, + }, + relation_type: kind.to_string(), + relation_type_formatted: kind.to_string(), + } + } + + fn anime_with(id: usize, relations: Vec) -> Anime { + let mut a = Anime::default(); + a.id = id; + a.title = format!("Anime {}", id); + a.related_anime = Some(relations); + a + } + + // build a fetcher backed by a fixed set of animes + fn fetcher(animes: Vec) -> impl FnMut(AnimeId) -> Option { + let map: HashMap = animes.into_iter().map(|a| (a.id, a)).collect(); + move |id| map.get(&id).cloned() + } + + #[test] + fn linear_chain_is_ordered() { + // 1 -> 2 -> 3 (sequels), root is 2 + let a1 = anime_with(1, vec![rel(2, SEQUEL)]); + let a2 = anime_with(2, vec![rel(1, PREQUEL), rel(3, SEQUEL)]); + let a3 = anime_with(3, vec![rel(2, PREQUEL)]); + let f = fetcher(vec![a1.clone(), a2.clone(), a3.clone()]); + + let (_animes, line, root_index, branches) = build_timeline(a2, f, |_, _, _| {}, |_, _, _| {}); + assert_eq!(line, vec![1, 2, 3]); + assert_eq!(root_index, 1); + assert!(branches.is_empty()); + } + + #[test] + fn extra_sequel_becomes_branch() { + // root 1 has two sequels: 2 (line) and 3 (branch) + let a1 = anime_with(1, vec![rel(2, SEQUEL), rel(3, SEQUEL)]); + let a2 = anime_with(2, vec![rel(1, PREQUEL)]); + let a3 = anime_with(3, vec![rel(1, PREQUEL)]); + let f = fetcher(vec![a1.clone(), a2.clone(), a3.clone()]); + + let (_a, line, _ri, branches) = build_timeline(a1, f, |_, _, _| {}, |_, _, _| {}); + assert_eq!(line, vec![1, 2]); + let b = branches.get(&1).expect("node 1 has a branch"); + assert_eq!(b.len(), 1); + assert_eq!(b[0].id, 3); + } + + #[test] + fn side_story_is_a_branch_not_on_line() { + let a1 = anime_with(1, vec![rel(5, "side_story")]); + let f = fetcher(vec![anime_with(5, vec![])]); + let (_a, line, _ri, branches) = build_timeline(a1, f, |_, _, _| {}, |_, _, _| {}); + assert_eq!(line, vec![1]); + assert_eq!(branches.get(&1).unwrap()[0].id, 5); + } + + #[test] + fn cycle_terminates() { + // 1 <-> 2 cyclic sequels + let a1 = anime_with(1, vec![rel(2, SEQUEL)]); + let a2 = anime_with(2, vec![rel(1, SEQUEL), rel(1, PREQUEL)]); + let f = fetcher(vec![a1.clone(), a2.clone()]); + let (_a, line, _ri, _b) = build_timeline(a1, f, |_, _, _| {}, |_, _, _| {}); + // visited-set prevents revisiting node 1 + assert_eq!(line, vec![1, 2]); + } + + #[test] + fn no_relations_single_node() { + let a1 = anime_with(42, vec![]); + let f = fetcher(vec![]); + let (_a, line, root_index, branches) = build_timeline(a1, f, |_, _, _| {}, |_, _, _| {}); + assert_eq!(line, vec![42]); + assert_eq!(root_index, 0); + assert!(branches.is_empty()); + } +} diff --git a/src/screens/seasons.rs b/src/screens/seasons.rs index 95d19e8..b5a488b 100644 --- a/src/screens/seasons.rs +++ b/src/screens/seasons.rs @@ -382,6 +382,11 @@ impl Screen for SeasonsScreen { ("Duration:", anime.average_episode_duration.to_string()), ("Rating:", anime.rating), ("Score:", anime.mean.to_string()), + ("Your Score:", if anime.my_list_status.score > 0 { + anime.my_list_status.score.to_string() + } else { + "-".to_string() + }), ("Ranked:", anime.rank.to_string()), ("Popularity:", anime.popularity.to_string()), ("Studios:", studios_string), diff --git a/src/screens/widgets/animebox.rs b/src/screens/widgets/animebox.rs index 83e1332..00b62fb 100644 --- a/src/screens/widgets/animebox.rs +++ b/src/screens/widgets/animebox.rs @@ -77,12 +77,7 @@ impl AnimeBox { ////////////////////////////////////// //////// Define the text info //////// ////////////////////////////////////// - let has_en_title = !anime.alternative_titles.en.is_empty(); - let title_text = if has_en_title { - anime.alternative_titles.en.clone() - } else { - anime.title.clone() - }; + let title_text = anime.display_title(); let info_text = "Scr: \nTyp: \nEpi: \nSta: \nSea: "; let season = DisplayString::new() @@ -105,7 +100,14 @@ impl AnimeBox { ) }; - let user_stats_value_text = anime.my_list_status.status.to_string(); + let user_stats_value_text = if anime.my_list_status.score > 0 { + format!( + "{}★({})", + anime.my_list_status.status, anime.my_list_status.score + ) + } else { + anime.my_list_status.status.to_string() + }; ////////////////////////////////////// //////// Color text elements //////// diff --git a/src/screens/widgets/popup.rs b/src/screens/widgets/popup.rs index fa94382..e83091e 100644 --- a/src/screens/widgets/popup.rs +++ b/src/screens/widgets/popup.rs @@ -5,7 +5,6 @@ use std::{ }, thread::JoinHandle, }; -use chrono::Utc; use crate::{ app::{Action, Event}, @@ -81,7 +80,7 @@ impl AnimePopup { pub fn new(info: ExtraInfo) -> Self { let buttons = vec![ "Play".to_string(), - "Nothing yet".to_string(), + "Related series".to_string(), "Play from start".to_string(), "Open".to_string(), ]; @@ -371,7 +370,8 @@ impl AnimePopup { return Some(Action::PlayAnime(self.anime_id)); } 1 => { - // play a specific episode + // jump to the Related screen, auto-building from this anime + return Some(Action::ShowRelated(self.anime_id)); } 2 => { @@ -673,11 +673,7 @@ impl AnimePopup { height: info_area.height.saturating_sub(buttons_area.height), }; - let title = if anime.alternative_titles.en.is_empty() { - anime.title.clone() - } else { - anime.alternative_titles.en.clone() - }; + let title = anime.display_title(); let title_text = Paragraph::new(title) .alignment(Alignment::Center) diff --git a/src/screens/widgets/sync.rs b/src/screens/widgets/sync.rs index 61e9826..6fdc186 100644 --- a/src/screens/widgets/sync.rs +++ b/src/screens/widgets/sync.rs @@ -534,11 +534,7 @@ impl SyncPopup { } // 2. Render Text Info - let title = if anime.alternative_titles.en.is_empty() { - &anime.title - } else { - &anime.alternative_titles.en - }; + let title = anime.display_title(); let [title_area, text_area] = Layout::default() .direction(Direction::Vertical)