├── .github └── workflows │ └── rust.yml ├── .gitignore ├── .vscode └── launch.json ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── assets ├── demo.gif ├── mal-client-id-page.png └── mal-tui-manga-details-page.png ├── config.example.yml ├── src ├── api │ ├── anime.rs │ ├── animelist.rs │ ├── manga.rs │ ├── mangalist.rs │ ├── mod.rs │ ├── model │ │ ├── anime.rs │ │ ├── manga.rs │ │ ├── mod.rs │ │ └── user.rs │ └── user.rs ├── app.rs ├── auth │ ├── cache.rs │ ├── mod.rs │ ├── redirect.rs │ └── token.rs ├── cli.rs ├── config │ ├── app_config.rs │ ├── mod.rs │ └── oauth_config.rs ├── event │ ├── events.rs │ ├── key.rs │ └── mod.rs ├── handlers │ ├── anime.rs │ ├── common.rs │ ├── display_block │ │ ├── anime_details.rs │ │ ├── manga_details.rs │ │ ├── mod.rs │ │ ├── ranking.rs │ │ ├── result.rs │ │ ├── seasonal.rs │ │ ├── top_three.rs │ │ ├── user_anime_list.rs │ │ └── user_manga_list.rs │ ├── help.rs │ ├── input.rs │ ├── mod.rs │ ├── option.rs │ └── user.rs ├── lib.rs ├── logging.rs ├── main.rs ├── network.rs └── ui │ ├── display_block │ ├── anime_details.rs │ ├── details_utils.rs │ ├── empty.rs │ ├── error.rs │ ├── loading.rs │ ├── manga_details.rs │ ├── mod.rs │ ├── ranking.rs │ ├── results.rs │ ├── search.rs │ ├── seasonal.rs │ ├── suggestion.rs │ ├── user.rs │ ├── user_anime_list.rs │ └── user_manga_list.rs │ ├── help.rs │ ├── mod.rs │ ├── side_menu.rs │ ├── top_three.rs │ └── util.rs └── todolist.txt /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /.vscode 3 | /todo.txt -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug unit tests in library 'mal'", 11 | "cargo": { 12 | "args": [ 13 | "test", 14 | "--no-run", 15 | "--lib", 16 | "--package=mal_cli" 17 | ], 18 | "filter": { 19 | "name": "mal", 20 | "kind": "lib" 21 | } 22 | }, 23 | "args": [], 24 | "cwd": "${workspaceFolder}" 25 | }, 26 | { 27 | "type": "lldb", 28 | "request": "launch", 29 | "name": "Debug executable 'mal'", 30 | "cargo": { 31 | "args": [ 32 | "build", 33 | "--bin=mal", 34 | "--package=mal_cli" 35 | ], 36 | "filter": { 37 | "name": "mal", 38 | "kind": "bin" 39 | } 40 | }, 41 | "args": [], 42 | "cwd": "${workspaceFolder}" 43 | }, 44 | { 45 | "type": "lldb", 46 | "request": "launch", 47 | "name": "Debug unit tests in executable 'mal'", 48 | "cargo": { 49 | "args": [ 50 | "test", 51 | "--no-run", 52 | "--bin=mal", 53 | "--package=mal_cli" 54 | ], 55 | "filter": { 56 | "name": "mal", 57 | "kind": "bin" 58 | } 59 | }, 60 | "args": [], 61 | "cwd": "${workspaceFolder}" 62 | } 63 | ] 64 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mal-cli-rs" 3 | authors = ["L4z3x ","Anas Saeed "] 4 | version = "0.2.1" 5 | edition = "2021" 6 | description = "CLI tool for myanimelist" 7 | repository = "https://github.com/L4z3x/mal-cli" 8 | license = "MIT" 9 | keywords = ["myanimelist", "cli", "anime","tui","terminal"] 10 | categories = ["command-line-utilities","api-bindings","command-line-interface"] 11 | 12 | [[bin]] 13 | name = "mal" 14 | path = "src/main.rs" 15 | test = false 16 | bench = false 17 | 18 | [lib] 19 | name = "mal" 20 | path = "src/lib.rs" 21 | 22 | [dependencies] 23 | better-panic = "0.3.0" 24 | bytes = "1.10.1" 25 | chrono = "0.4.40" 26 | clap = { version = "4.5.39", features = ["derive"] } 27 | color-eyre = "0.6.3" 28 | crossterm = "0.28.1" 29 | dirs = "6.0.0" 30 | figlet-rs = "0.1.5" 31 | httparse = "1.10.1" 32 | image = "0.25.5" 33 | log = { version = "0.4.27", features = ["serde"] } 34 | rand = "0.9.0" 35 | ratatui = { version = "0.29.0", features = ["serde"] } 36 | ratatui-image = "5.0.0" 37 | regex = "1.11.1" 38 | reqwest = { version = "0.12.12", features = ["json", "rustls-tls","blocking"],default-features = false } 39 | serde = "1.0.219" 40 | serde_json = "1.0.140" 41 | serde_urlencoded = "0.7.1" 42 | serde_yaml = "0.9.34" 43 | strum = "0.27.1" 44 | strum_macros = "0.27.1" 45 | time = { version = "0.3.39" , features = ["parsing" , "formatting"] } 46 | tokio = {version = "1.44.0",features = ["full"]} 47 | tracing = "0.1.41" 48 | tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 49 | tui-big-text = "0.7.1" 50 | tui-logger = { version = "0.17.3", features = ["tracing-support"] } 51 | tui-scrollview = "0.5.1" 52 | unicode-width = "0.2.0" 53 | url = "2.5.4" 54 | webbrowser = "1.0.3" 55 | 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Moussa Mousselmal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build build-release build-linux-musl-debug build-linux-musl-release clippy fmt audit clean check install help 2 | 3 | # Default target 4 | all: build 5 | 6 | # Build Debug 7 | build: 8 | cargo build 9 | 10 | # Build Release 11 | build-release: 12 | cargo build --release 13 | 14 | # Build Debug with MUSL (static binary) 15 | build-linux-musl-debug: 16 | cargo build --target x86_64-unknown-linux-musl 17 | 18 | # Build Release with MUSL 19 | build-linux-musl-release: 20 | cargo build --release --target x86_64-unknown-linux-musl 21 | 22 | # Run Clippy with all targets/features, fail on warnings 23 | clippy: 24 | cargo clippy --all-targets --all-features -- -D warnings 25 | 26 | # Format code 27 | fmt: 28 | cargo fmt 29 | 30 | # Check formatting without making changes 31 | fmt-check: 32 | cargo fmt -- --check 33 | 34 | # Audit for vulnerable crates 35 | audit: 36 | cargo install --quiet cargo-audit || true 37 | cargo audit 38 | 39 | # Clean build artifacts 40 | clean: 41 | cargo clean 42 | 43 | # Run all checks (format, clippy, build) 44 | check: fmt-check clippy build 45 | 46 | # Install the binary to ~/.cargo/bin 47 | install: 48 | cargo install --path . 49 | 50 | # Install the binary to ~/.cargo/bin (release mode) 51 | install-release: 52 | cargo install --path . --release 53 | 54 | # Show help 55 | help: 56 | @echo "Available targets:" 57 | @echo " build - Build debug version" 58 | @echo " build-release - Build release version" 59 | @echo " build-linux-musl-debug - Build debug static binary (Linux MUSL)" 60 | @echo " build-linux-musl-release- Build release static binary (Linux MUSL)" 61 | @echo " clippy - Run clippy linter" 62 | @echo " fmt - Format code" 63 | @echo " fmt-check - Check code formatting" 64 | @echo " audit - Run security audit" 65 | @echo " clean - Clean build artifacts" 66 | @echo " check - Run all checks (fmt, clippy, build)" 67 | @echo " install - Install binary (debug)" 68 | @echo " install-release - Install binary (release)" 69 | @echo " help - Show this help message" 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | ![Build](https://img.shields.io/github/actions/workflow/status/L4z3x/mal-tui/rust.yml) ![Crates.io](https://img.shields.io/crates/v/mal-cli-rs) ![License](https://img.shields.io/github/license/L4z3x/mal-tui) ![Stars](https://img.shields.io/github/stars/L4z3x/mal-tui?style=social) 4 | 5 |
6 | 7 | # MAL-Cli 8 | 🎌 A fast, keyboard-driven terminal client for [MyAnimeList](https://myanimelist.net/) – built with Rust and Ratatui. 9 | 10 | 11 | ## Note: 12 | - for rendering images use a gpu-enhanced terminal like kitty, and for windows use windows terminal >1.22 13 | 14 | 15 | # Demo: 16 |
17 | 18 | ![gif](./assets/demo.gif) 19 | 20 |
21 | 22 | ## Detail page 23 | 24 |
25 | 26 | ![detail](./assets/mal-tui-manga-details-page.png) 27 | 28 |
29 | 30 | # INSTALLATION: 31 | ## ArchLinux: 32 | ``` 33 | yay -S mal-cli 34 | ``` 35 | 36 | ## using cargo: 37 | ``` 38 | cargo install mal-cli-rs 39 | ``` 40 | 41 | ## Debian-based: 42 | download the package from last release and run: 43 | ``` 44 | sudo dpkg -i 45 | ``` 46 | release section can be found here [here](https://github.com/L4z3x/mal-cli/releases/) 47 | 48 | ## windows/ macos / musl: 49 | download binaries from release section and run directly otherwise use cargo 50 | ## 51 | # HOW TO GET CLIENT ID: 52 | visit [mal](https://myanimelist.net/apiconfig/create) 53 | and if you get an error, go to your profile -> profile settings -> api -> create 54 | ![image](./assets/mal-client-id-page.png) 55 | 56 | 57 | ## Main keys: 58 | - [s]: switching/opening popups 59 | - [r]: opening popups (when s does the switching) 60 | - [Ctrl+p]: forward navigation 61 | - [Esc]: backward navigation 62 | 63 | 64 | 65 | # Debug: 66 | in $HOME/.config/mal-tui/config.yml file: 67 | set show_logger to true 68 | set log_level to INFO 69 | 70 | # Aknowledgement: 71 | - this repo was forked from [SaeedAnas/mal-cli](https://github.com/SaeedAnas/mal-cli) (last commit 5 years ago) 72 | 73 | # TODO: 74 | - [ ] add help section 75 | - [ ] add delete entry endpoint 76 | - [ ] fix double click on windows 77 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L4z3x/mal-cli/bae73a2a68f845cd9e3ee30a72825670d5d4029f/assets/demo.gif -------------------------------------------------------------------------------- /assets/mal-client-id-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L4z3x/mal-cli/bae73a2a68f845cd9e3ee30a72825670d5d4029f/assets/mal-client-id-page.png -------------------------------------------------------------------------------- /assets/mal-tui-manga-details-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L4z3x/mal-cli/bae73a2a68f845cd9e3ee30a72825670d5d4029f/assets/mal-tui-manga-details-page.png -------------------------------------------------------------------------------- /config.example.yml: -------------------------------------------------------------------------------- 1 | keys: 2 | help: !char '?' 3 | back: !char 'q' 4 | search: !char '/' 5 | toggle: !char 's' 6 | next_state: !ctrl 'p' 7 | open_popup: !char 'r' 8 | theme: 9 | mal_color: '#2E51A2' 10 | active: Cyan 11 | banner: '#2E51A2' 12 | hovered: Magenta 13 | text: White 14 | selected: LightCyan 15 | error_border: Red 16 | error_text: LightRed 17 | inactive: Gray 18 | status_completed: Green 19 | status_dropped: Gray 20 | status_on_hold: Yellow 21 | status_watching: Blue 22 | status_plan_to_watch: LightMagenta 23 | status_other: White 24 | behavior: 25 | tick_rate_milliseconds: 500 26 | show_logger: false 27 | nsfw: false 28 | title_language: English 29 | manga_display_type: Both 30 | top_three_anime_types: 31 | - airing 32 | - all 33 | - upcoming 34 | - movie 35 | - special 36 | - ova 37 | - tv 38 | - popularity 39 | - favorite 40 | top_three_manga_types: 41 | - all 42 | - manga 43 | - novels 44 | - oneshots 45 | - doujinshi 46 | - manhwa 47 | - manhua 48 | - bypopularity 49 | - favorite 50 | navigation_stack_limit: 15 51 | search_limit: 30 52 | max_cached_images: 15 53 | -------------------------------------------------------------------------------- /src/api/anime.rs: -------------------------------------------------------------------------------- 1 | use super::model::*; 2 | use super::Error; 3 | use super::{get, handle_response, API_URL}; 4 | use crate::auth::OAuth; 5 | use serde::Serialize; 6 | 7 | /// Get Anime List Request 8 | #[derive(Clone, Debug, Serialize)] 9 | pub struct GetAnimeListQuery { 10 | pub q: String, 11 | pub limit: u64, 12 | pub offset: u64, 13 | pub nsfw: bool, 14 | #[serde(skip_serializing_if = "Option::is_none")] 15 | pub fields: Option, 16 | } 17 | 18 | pub async fn get_anime_list(query: &GetAnimeListQuery, auth: &OAuth) -> Result, Error> { 19 | let response = get( 20 | &format!("{}/anime?{}", API_URL, serde_urlencoded::to_string(query)?), 21 | auth, 22 | ) 23 | .await?; 24 | handle_response(&response) 25 | } 26 | 27 | #[derive(Clone, Debug, Serialize)] 28 | pub struct GetAnimeDetailQuery { 29 | #[serde(skip_serializing_if = "Option::is_none")] 30 | pub fields: Option, 31 | pub nsfw: bool, 32 | } 33 | 34 | pub async fn get_anime_details( 35 | anime_id: u64, 36 | query: &GetAnimeDetailQuery, 37 | auth: &OAuth, 38 | ) -> Result { 39 | let response = get( 40 | &format!( 41 | "{}/anime/{}?{}", 42 | API_URL, 43 | anime_id, 44 | serde_urlencoded::to_string(query)? 45 | ), 46 | auth, 47 | ) 48 | .await?; 49 | handle_response(&response) 50 | } 51 | 52 | #[derive(Clone, Debug, Serialize)] 53 | pub struct GetAnimeRankingQuery { 54 | pub ranking_type: AnimeRankingType, 55 | pub limit: u64, 56 | pub offset: u64, 57 | pub nsfw: bool, 58 | #[serde(skip_serializing_if = "Option::is_none")] 59 | pub fields: Option, 60 | } 61 | 62 | pub async fn get_anime_ranking( 63 | query: &GetAnimeRankingQuery, 64 | auth: &OAuth, 65 | ) -> Result, Error> { 66 | let response = get( 67 | &format!( 68 | "{}/anime/ranking?{}", 69 | API_URL, 70 | serde_urlencoded::to_string(query)? 71 | ), 72 | auth, 73 | ) 74 | .await?; 75 | handle_response(&response) 76 | } 77 | 78 | #[derive(Clone, Debug, Serialize)] 79 | pub struct GetSeasonalAnimeQuery { 80 | pub sort: Option, 81 | pub limit: u64, 82 | pub offset: u64, 83 | pub nsfw: bool, 84 | #[serde(skip_serializing_if = "Option::is_none")] 85 | pub fields: Option, 86 | } 87 | 88 | pub async fn get_seasonal_anime( 89 | season: &AnimeSeason, 90 | query: &GetSeasonalAnimeQuery, 91 | auth: &OAuth, 92 | ) -> Result, Error> { 93 | let season_name: &'static str = season.season.clone().into(); 94 | let response = get( 95 | &format!( 96 | "{}/anime/season/{}/{}?{}", 97 | API_URL, 98 | season.year, 99 | season_name, 100 | serde_urlencoded::to_string(query)? 101 | ), 102 | auth, 103 | ) 104 | .await?; 105 | handle_response(&response) 106 | } 107 | 108 | #[derive(Clone, Debug, Serialize)] 109 | pub struct GetSuggestedAnimeQuery { 110 | pub limit: u64, 111 | pub offset: u64, 112 | pub nsfw: bool, 113 | #[serde(skip_serializing_if = "Option::is_none")] 114 | pub fields: Option, 115 | } 116 | 117 | pub async fn get_suggested_anime( 118 | query: &GetSuggestedAnimeQuery, 119 | auth: &OAuth, 120 | ) -> Result, Error> { 121 | let response = get( 122 | &format!( 123 | "{}/anime/suggestions?{}", 124 | API_URL, 125 | serde_urlencoded::to_string(query)? 126 | ), 127 | auth, 128 | ) 129 | .await?; 130 | handle_response(&response) 131 | } 132 | 133 | #[cfg(test)] 134 | pub mod tests { 135 | 136 | use super::*; 137 | 138 | pub async fn get_anime(q: T, auth: &OAuth) -> Result { 139 | let anime_query = GetAnimeListQuery { 140 | q: q.to_string(), 141 | limit: 4, 142 | offset: 0, 143 | nsfw: false, 144 | fields: Some(ALL_ANIME_AND_MANGA_FIELDS.to_string()), 145 | }; 146 | let anime_list = get_anime_list(&anime_query, auth).await.unwrap(); 147 | let anime = anime_list.data.first().unwrap().node.clone(); 148 | Ok(anime) 149 | } 150 | 151 | #[tokio::test] 152 | async fn test_get_anime_list() { 153 | let mut auth = crate::auth::tests::get_auth(); 154 | auth.refresh().unwrap(); 155 | // let oauth = crate::auth::OAuth::get_auth(app_config::AppConfig::Load()) 156 | let query = GetAnimeListQuery { 157 | q: "Code Geass".to_string(), 158 | limit: 4, 159 | offset: 0, 160 | nsfw: false, 161 | fields: Some(ALL_ANIME_AND_MANGA_FIELDS.to_string()), 162 | }; 163 | let result = get_anime_list(&query, &auth).await.unwrap(); 164 | println!("{:#?}", result); 165 | assert!(!result.data.is_empty()); 166 | } 167 | 168 | #[tokio::test] 169 | async fn test_get_anime_details() { 170 | let auth = crate::auth::tests::get_auth(); 171 | let query = GetAnimeDetailQuery { 172 | fields: Some(ALL_ANIME_AND_MANGA_FIELDS.to_string()), 173 | nsfw: false, 174 | }; 175 | 176 | let anime = get_anime("Cowboy Bebop", &auth).await.unwrap(); 177 | let result = get_anime_details(anime.id, &query, &auth).await.unwrap(); 178 | println!("{:#?}", result); 179 | assert_eq!(result.title, anime.title); 180 | } 181 | 182 | #[tokio::test] 183 | async fn test_get_anime_ranking() { 184 | let auth = crate::auth::tests::get_auth(); 185 | let query = GetAnimeRankingQuery { 186 | ranking_type: AnimeRankingType::All, 187 | limit: 4, 188 | offset: 0, 189 | nsfw: false, 190 | fields: Some(ALL_ANIME_AND_MANGA_FIELDS.to_string()), 191 | }; 192 | let result = get_anime_ranking(&query, &auth).await.unwrap(); 193 | println!("{:#?}", result); 194 | assert!(!result.data.is_empty()); 195 | } 196 | #[tokio::test] 197 | async fn test_get_seasonal_anime() { 198 | let auth = crate::auth::tests::get_auth(); 199 | let query = GetSeasonalAnimeQuery { 200 | sort: None, 201 | limit: 4, 202 | offset: 0, 203 | nsfw: false, 204 | fields: Some(ALL_ANIME_AND_MANGA_FIELDS.to_string()), 205 | }; 206 | let season = AnimeSeason { 207 | year: 2020, 208 | season: Season::Summer, 209 | }; 210 | let result = get_seasonal_anime(&season, &query, &auth).await.unwrap(); 211 | println!("{:#?}", result); 212 | assert!(!result.data.is_empty()); 213 | } 214 | #[tokio::test] 215 | async fn test_get_suggested_anime() { 216 | let auth = crate::auth::tests::get_auth(); 217 | let query = GetSuggestedAnimeQuery { 218 | limit: 4, 219 | offset: 0, 220 | nsfw: false, 221 | fields: Some(ALL_ANIME_AND_MANGA_FIELDS.to_string()), 222 | }; 223 | let result = get_suggested_anime(&query, &auth).await.unwrap(); 224 | println!("{:#?}", result); 225 | assert!(!result.data.is_empty()); 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/api/animelist.rs: -------------------------------------------------------------------------------- 1 | use super::model::*; 2 | use super::Error; 3 | use super::{delete, get, handle_response, patch, API_URL}; 4 | use crate::auth::OAuth; 5 | use serde::Serialize; 6 | 7 | /// Update specified anime in animelist 8 | #[derive(Clone, Debug, Serialize)] 9 | pub struct UpdateUserAnimeListStatusQuery { 10 | #[serde(skip_serializing_if = "Option::is_none")] 11 | pub status: Option, 12 | #[serde(skip_serializing_if = "Option::is_none")] 13 | pub is_rewatching: Option, 14 | #[serde(skip_serializing_if = "Option::is_none")] 15 | pub score: Option, 16 | #[serde(skip_serializing_if = "Option::is_none")] 17 | pub num_watched_episodes: Option, 18 | #[serde(skip_serializing_if = "Option::is_none")] 19 | pub priority: Option, 20 | #[serde(skip_serializing_if = "Option::is_none")] 21 | pub num_times_rewatched: Option, 22 | #[serde(skip_serializing_if = "Option::is_none")] 23 | pub rewatch_value: Option, 24 | #[serde(skip_serializing_if = "Option::is_none")] 25 | pub tags: Option, 26 | #[serde(skip_serializing_if = "Option::is_none")] 27 | pub comments: Option, 28 | } 29 | 30 | pub async fn update_anime_list_status( 31 | anime_id: u64, 32 | update: &UpdateUserAnimeListStatusQuery, 33 | auth: &OAuth, 34 | ) -> Result { 35 | let response = patch( 36 | &format!("{}/anime/{}/my_list_status", API_URL, anime_id,), 37 | auth, 38 | update, 39 | ) 40 | .await?; 41 | handle_response(&response) 42 | } 43 | 44 | pub async fn delete_anime_from_list(anime_id: u64, auth: &OAuth) -> Result<(), Error> { 45 | let response = delete( 46 | &format!("{}/anime/{}/my_list_status", API_URL, anime_id), 47 | auth, 48 | ) 49 | .await?; 50 | if response.status.is_success() { 51 | Ok(()) 52 | } else { 53 | Err(Error::HttpError(response.status)) 54 | } 55 | } 56 | 57 | #[derive(Clone, Debug, Serialize)] 58 | pub struct GetUserAnimeListQuery { 59 | #[serde(skip_serializing_if = "Option::is_none")] 60 | pub fields: Option, 61 | #[serde(skip_serializing_if = "Option::is_none")] 62 | pub status: Option, 63 | #[serde(skip_serializing_if = "Option::is_none")] 64 | pub sort: Option, 65 | pub limit: u64, 66 | pub offset: u64, 67 | pub nsfw: bool, 68 | } 69 | 70 | pub async fn get_user_anime_list( 71 | user: U, 72 | query: &GetUserAnimeListQuery, 73 | auth: &OAuth, 74 | ) -> Result, Error> { 75 | let response = get( 76 | &format!( 77 | "{}/users/{}/animelist?{}", 78 | API_URL, 79 | user.to_string(), 80 | serde_urlencoded::to_string(query)? 81 | ), 82 | auth, 83 | ) 84 | .await?; 85 | handle_response(&response) 86 | } 87 | 88 | #[cfg(test)] 89 | mod tests { 90 | use super::*; 91 | use crate::api::anime::tests::*; 92 | 93 | #[tokio::test] 94 | #[ignore] 95 | async fn test_delete_anime_from_list() { 96 | let auth = crate::auth::tests::get_auth(); 97 | let anime = get_anime("God of High School", &auth).await.unwrap(); 98 | delete_anime_from_list(anime.id, &auth).await.unwrap(); 99 | } 100 | 101 | #[tokio::test] 102 | async fn test_update_anime_list() { 103 | let auth = crate::auth::tests::get_auth(); 104 | let query = UpdateUserAnimeListStatusQuery { 105 | status: Some(UserWatchStatus::Completed), 106 | is_rewatching: None, 107 | score: Some(8), 108 | num_watched_episodes: Some(13), 109 | priority: None, 110 | num_times_rewatched: None, 111 | rewatch_value: None, 112 | tags: None, 113 | comments: None, 114 | }; 115 | 116 | let anime = get_anime( 117 | "Yahari Ore no Seishun Love Comedy wa Machigatteiru. Kan", 118 | &auth, 119 | ) 120 | .await 121 | .unwrap(); 122 | 123 | let result = update_anime_list_status(anime.id, &query, &auth) 124 | .await 125 | .unwrap(); 126 | println!("{:#?}", result); 127 | assert_eq!(result.num_episodes_watched, 5); 128 | } 129 | 130 | #[tokio::test] 131 | async fn test_get_user_anime_list() { 132 | let auth = crate::auth::tests::get_auth(); 133 | let query = GetUserAnimeListQuery { 134 | fields: Some(ALL_ANIME_AND_MANGA_FIELDS.to_string()), 135 | status: None, 136 | sort: Some(SortStyle::ListScore), 137 | limit: 2, 138 | offset: 0, 139 | nsfw: true, 140 | }; 141 | let result = get_user_anime_list("@me", &query, &auth).await.unwrap(); 142 | 143 | print!("{:#?}", result); 144 | 145 | assert!(!result.data.is_empty()); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/api/manga.rs: -------------------------------------------------------------------------------- 1 | use super::model::*; 2 | use super::Error; 3 | use super::{get, handle_response, API_URL}; 4 | use crate::auth::OAuth; 5 | use serde::Serialize; 6 | 7 | #[derive(Clone, Debug, Serialize)] 8 | pub struct GetMangaListQuery { 9 | pub q: String, 10 | pub limit: u64, 11 | pub offset: u64, 12 | pub nsfw: bool, 13 | #[serde(skip_serializing_if = "Option::is_none")] 14 | pub fields: Option, 15 | } 16 | 17 | pub async fn get_manga_list(query: &GetMangaListQuery, auth: &OAuth) -> Result, Error> { 18 | let response = get( 19 | &format! {"{}/manga?{}", API_URL, serde_urlencoded::to_string(query)?}, 20 | auth, 21 | ) 22 | .await?; 23 | handle_response(&response) 24 | } 25 | 26 | #[derive(Clone, Debug, Serialize)] 27 | pub struct GetMangaDetailQuery { 28 | #[serde(skip_serializing_if = "Option::is_none")] 29 | pub fields: Option, 30 | pub nsfw: bool, 31 | } 32 | 33 | pub async fn get_manga_details( 34 | manga_id: u64, 35 | query: &GetMangaDetailQuery, 36 | auth: &OAuth, 37 | ) -> Result { 38 | let response = get( 39 | &format!( 40 | "{}/manga/{}?{}", 41 | API_URL, 42 | manga_id, 43 | serde_urlencoded::to_string(query)? 44 | ), 45 | auth, 46 | ) 47 | .await?; 48 | handle_response(&response) 49 | } 50 | 51 | #[derive(Clone, Debug, Serialize)] 52 | pub struct GetMangaRankingQuery { 53 | pub ranking_type: MangaRankingType, 54 | pub limit: u64, 55 | pub offset: u64, 56 | pub nsfw: bool, 57 | #[serde(skip_serializing_if = "Option::is_none")] 58 | pub fields: Option, 59 | } 60 | 61 | pub async fn get_manga_ranking( 62 | query: &GetMangaRankingQuery, 63 | auth: &OAuth, 64 | ) -> Result, Error> { 65 | let response = get( 66 | &format!( 67 | "{}/manga/ranking?{}", 68 | API_URL, 69 | serde_urlencoded::to_string(query)? 70 | ), 71 | auth, 72 | ) 73 | .await?; 74 | handle_response(&response) 75 | } 76 | 77 | #[cfg(test)] 78 | pub mod tests { 79 | use super::*; 80 | 81 | pub async fn get_manga(q: T, auth: &OAuth) -> Result { 82 | let manga_query = GetMangaListQuery { 83 | q: q.to_string(), 84 | limit: 4, 85 | offset: 0, 86 | nsfw: false, 87 | fields: Some(ALL_ANIME_AND_MANGA_FIELDS.to_string()), 88 | }; 89 | let manga_list = get_manga_list(&manga_query, auth).await.unwrap(); 90 | let manga = manga_list.data.first().unwrap().node.clone(); 91 | Ok(manga) 92 | } 93 | 94 | #[tokio::test] 95 | async fn test_get_manga_list() { 96 | let auth = crate::auth::tests::get_auth(); 97 | let query = GetMangaListQuery { 98 | q: "Kaguya-Sama Wa Kokurasetai".to_string(), 99 | limit: 2, 100 | offset: 0, 101 | nsfw: false, 102 | fields: Some(ALL_ANIME_AND_MANGA_FIELDS.to_string()), 103 | }; 104 | let result = get_manga_list(&query, &auth).await.unwrap(); 105 | println!("{:#?}", result); 106 | assert!(!result.data.is_empty()); 107 | } 108 | 109 | #[tokio::test] 110 | async fn test_get_manga_details() { 111 | let auth = crate::auth::tests::get_auth(); 112 | let query = GetMangaDetailQuery { 113 | fields: Some(ALL_ANIME_AND_MANGA_FIELDS.to_string()), 114 | nsfw: false, 115 | }; 116 | 117 | let manga = get_manga("Kaguya-Sama Wa Kokurasetai", &auth) 118 | .await 119 | .unwrap(); 120 | let result = get_manga_details(manga.id, &query, &auth).await.unwrap(); 121 | println!("{:#?}", result); 122 | assert_eq!(result.title, manga.title); 123 | } 124 | 125 | #[tokio::test] 126 | async fn test_get_manga_ranking() { 127 | let auth = crate::auth::tests::get_auth(); 128 | let query = GetMangaRankingQuery { 129 | ranking_type: MangaRankingType::All, 130 | limit: 100, 131 | offset: 0, 132 | nsfw: false, 133 | fields: Some(ALL_ANIME_AND_MANGA_FIELDS.to_string()), 134 | }; 135 | let result = get_manga_ranking(&query, &auth).await.unwrap(); 136 | println!("{:#?}", result); 137 | assert!(!result.data.is_empty()); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/api/mangalist.rs: -------------------------------------------------------------------------------- 1 | use super::model::*; 2 | use super::Error; 3 | use super::{delete, get, handle_response, patch, API_URL}; 4 | use crate::auth::OAuth; 5 | use serde::Serialize; 6 | 7 | #[derive(Clone, Debug, Serialize)] 8 | pub struct UpdateUserMangaStatus { 9 | #[serde(skip_serializing_if = "Option::is_none")] 10 | pub status: Option, 11 | #[serde(skip_serializing_if = "Option::is_none")] 12 | pub is_rereading: Option, 13 | #[serde(skip_serializing_if = "Option::is_none")] 14 | pub score: Option, 15 | #[serde(skip_serializing_if = "Option::is_none")] 16 | pub num_volumes_read: Option, 17 | #[serde(skip_serializing_if = "Option::is_none")] 18 | pub num_chapters_read: Option, 19 | #[serde(skip_serializing_if = "Option::is_none")] 20 | pub priority: Option, 21 | #[serde(skip_serializing_if = "Option::is_none")] 22 | pub num_times_reread: Option, 23 | #[serde(skip_serializing_if = "Option::is_none")] 24 | pub reread_value: Option, 25 | #[serde(skip_serializing_if = "Option::is_none")] 26 | pub tags: Option, 27 | #[serde(skip_serializing_if = "Option::is_none")] 28 | pub comments: Option, 29 | } 30 | 31 | pub async fn update_manga_list_status( 32 | manga_id: u64, 33 | update: &UpdateUserMangaStatus, 34 | auth: &OAuth, 35 | ) -> Result { 36 | let response = patch( 37 | &format!("{}/manga/{}/my_list_status", API_URL, manga_id), 38 | auth, 39 | update, 40 | ) 41 | .await?; 42 | handle_response(&response) 43 | } 44 | 45 | pub async fn delete_manga_from_list(manga_id: u64, auth: &OAuth) -> Result<(), Error> { 46 | let response = delete( 47 | &format!("{}/manga/{}/my_list_status", API_URL, manga_id), 48 | auth, 49 | ) 50 | .await?; 51 | if response.status.is_success() { 52 | Ok(()) 53 | } else { 54 | Err(Error::HttpError(response.status)) 55 | } 56 | } 57 | 58 | #[derive(Clone, Debug, Serialize)] 59 | pub struct GetUserMangaListQuery { 60 | #[serde(skip_serializing_if = "Option::is_none")] 61 | pub fields: Option, 62 | #[serde(skip_serializing_if = "Option::is_none")] 63 | pub status: Option, 64 | #[serde(skip_serializing_if = "Option::is_none")] 65 | pub sort: Option, 66 | pub limit: u64, 67 | pub offset: u64, 68 | pub nsfw: bool, 69 | } 70 | 71 | pub async fn get_user_manga_list( 72 | user: U, 73 | query: &GetUserMangaListQuery, 74 | auth: &OAuth, 75 | ) -> Result, Error> { 76 | let response = get( 77 | &format!( 78 | "{}/users/{}/mangalist?{}", 79 | API_URL, 80 | user.to_string(), 81 | serde_urlencoded::to_string(query)? 82 | ), 83 | auth, 84 | ) 85 | .await?; 86 | handle_response(&response) 87 | } 88 | 89 | #[cfg(test)] 90 | mod test { 91 | use super::*; 92 | use crate::api::manga::tests::*; 93 | 94 | #[tokio::test] 95 | async fn test_delete_manga_from_list() { 96 | let auth = crate::auth::tests::get_auth(); 97 | let manga = get_manga("Grand Blue", &auth).await.unwrap(); 98 | delete_manga_from_list(manga.id, &auth).await.unwrap(); 99 | } 100 | 101 | #[tokio::test] 102 | async fn test_update_manga_list() { 103 | let auth = crate::auth::tests::get_auth(); 104 | let query = UpdateUserMangaStatus { 105 | status: Some(UserReadStatus::Reading), 106 | is_rereading: None, 107 | score: Some(9), 108 | num_volumes_read: None, 109 | num_chapters_read: Some(62), 110 | priority: None, 111 | num_times_reread: None, 112 | reread_value: None, 113 | tags: None, 114 | comments: None, 115 | }; 116 | let manga = get_manga("Grand Blue", &auth).await.unwrap(); 117 | let result = update_manga_list_status(manga.id, &query, &auth) 118 | .await 119 | .unwrap(); 120 | println!("{:#?}", result); 121 | assert_eq!(result.num_chapters_read, 62); 122 | } 123 | 124 | #[tokio::test] 125 | async fn test_get_user_manga_list() { 126 | let auth = crate::auth::tests::get_auth(); 127 | let query = GetUserMangaListQuery { 128 | fields: Some(ALL_ANIME_AND_MANGA_FIELDS.to_string()), 129 | status: None, 130 | sort: None, 131 | limit: 100, 132 | offset: 0, 133 | nsfw: true, 134 | }; 135 | let result = get_user_manga_list("@me", &query, &auth).await.unwrap(); 136 | 137 | print!("{:#?}", result); 138 | 139 | assert!(!result.data.is_empty()); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/api/mod.rs: -------------------------------------------------------------------------------- 1 | #![allow(ambiguous_glob_reexports)] 2 | /// Anime API endpoints 3 | pub mod anime; 4 | pub use anime::*; 5 | /// User animelist API endpoints 6 | pub mod animelist; 7 | pub use animelist::*; 8 | /// manga API endpoints 9 | pub mod manga; 10 | pub use manga::*; 11 | /// User mangalist API endpoints 12 | pub mod mangalist; 13 | pub use mangalist::*; 14 | /// API objects 15 | pub mod model; 16 | /// User API endpoints 17 | pub mod user; 18 | pub use user::*; 19 | 20 | use crate::auth::OAuth; 21 | use reqwest::{ClientBuilder, RequestBuilder}; 22 | use serde::{Deserialize, Serialize}; 23 | 24 | pub const API_URL: &str = "https://api.myanimelist.net/v2"; 25 | 26 | #[derive(Debug)] 27 | pub enum Error { 28 | NoAuth, 29 | TimedOut, 30 | Unknown, 31 | NoBody, 32 | ParseError(serde_json::Error), 33 | QuerySerializeError(serde_urlencoded::ser::Error), 34 | HttpError(reqwest::StatusCode), 35 | } 36 | 37 | impl From for Error { 38 | fn from(e: reqwest::Error) -> Self { 39 | if e.is_timeout() { 40 | Error::TimedOut 41 | } else { 42 | Error::Unknown 43 | } 44 | } 45 | } 46 | 47 | impl From for Error { 48 | fn from(e: serde_json::Error) -> Self { 49 | Error::ParseError(e) 50 | } 51 | } 52 | 53 | impl From for Error { 54 | fn from(e: serde_urlencoded::ser::Error) -> Self { 55 | Error::QuerySerializeError(e) 56 | } 57 | } 58 | 59 | #[derive(Debug)] 60 | pub(crate) struct ApiResponse { 61 | status: reqwest::StatusCode, 62 | body: Option, 63 | } 64 | 65 | type ApiResult = Result; 66 | 67 | pub(crate) fn apply_headers(req: RequestBuilder, auth: &OAuth) -> ApiResult { 68 | let access_token = match auth.token() { 69 | Some(token) => &token.token.access_token, 70 | None => return Err(Error::NoAuth), 71 | }; 72 | Ok(req 73 | .header(reqwest::header::ACCEPT, "application/json") 74 | .header( 75 | reqwest::header::CONTENT_TYPE, 76 | "application/x-www-form-urlencoded", 77 | ) 78 | .header( 79 | reqwest::header::AUTHORIZATION, 80 | format!("Bearer {}", access_token), 81 | )) 82 | } 83 | 84 | pub(crate) async fn send(request: RequestBuilder, auth: &OAuth) -> ApiResult { 85 | let request = apply_headers(request, auth)?; 86 | let response = request.send().await?; 87 | let status = response.status(); 88 | Ok(ApiResponse { 89 | status, 90 | body: (response.text().await).ok(), 91 | }) 92 | } 93 | 94 | pub(crate) async fn get(url: U, auth: &OAuth) -> ApiResult { 95 | let request = ClientBuilder::new() 96 | .user_agent(auth.user_agent()) 97 | .build()? 98 | .get(url); 99 | send(request, auth).await 100 | } 101 | 102 | pub(crate) async fn patch( 103 | url: U, 104 | auth: &OAuth, 105 | body: &B, 106 | ) -> ApiResult { 107 | let request = ClientBuilder::new() 108 | .user_agent(auth.user_agent()) 109 | .build()? 110 | .patch(url) 111 | .body(serde_urlencoded::to_string(body)?); 112 | send(request, auth).await 113 | } 114 | 115 | pub(crate) async fn delete(url: U, auth: &OAuth) -> ApiResult { 116 | let request = ClientBuilder::new() 117 | .user_agent(auth.user_agent()) 118 | .build()? 119 | .delete(url); 120 | send(request, auth).await 121 | } 122 | 123 | pub(crate) fn handle_response<'a, D: Deserialize<'a>>(res: &'a ApiResponse) -> ApiResult { 124 | if !res.status.is_success() { 125 | return Err(Error::HttpError(res.status)); 126 | } 127 | if let Some(body) = &res.body { 128 | Ok(serde_json::from_str::(body)?) 129 | } else { 130 | Err(Error::NoBody) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/api/model/anime.rs: -------------------------------------------------------------------------------- 1 | use crate::config::app_config::{AppConfig, TitleLanguage}; 2 | 3 | use super::*; 4 | use serde::{Deserialize, Serialize}; 5 | use std::fmt::Debug; 6 | use strum_macros::{Display, EnumIter, EnumString, IntoStaticStr}; 7 | 8 | #[derive(Clone, Debug, Deserialize, Serialize)] 9 | pub struct AnimeSeason { 10 | pub year: u64, 11 | pub season: Season, 12 | } 13 | 14 | #[derive(Clone, Debug, PartialEq, EnumString, IntoStaticStr)] 15 | #[strum(serialize_all = "snake_case")] 16 | pub enum AnimeField { 17 | Id, 18 | Titel, 19 | MainPicture, 20 | AlternativeTitles, 21 | StartDate, 22 | EndDate, 23 | Synopsis, 24 | Mean, 25 | Rank, 26 | Popularity, 27 | NumListUsers, 28 | NumScoringUsers, 29 | NSFW, 30 | CreatedAt, 31 | UpdatedAt, 32 | MediaType, 33 | Status, 34 | MyListStatus, 35 | NumEpisodes, 36 | Broadcast, 37 | Source, 38 | AverageEpisodeDuration, 39 | Rating, 40 | Pictures, 41 | Background, 42 | RelatedAnime, 43 | RelatedManga, 44 | Recommendations, 45 | Studios, 46 | Statistics, 47 | 48 | NumVolumes, 49 | NumChapters, 50 | Authors, 51 | 52 | Name, 53 | Picture, 54 | Gender, 55 | Birthday, 56 | Location, 57 | JoinedAt, 58 | AnimeStatistics, 59 | TimeZone, 60 | IsSupporter, 61 | } 62 | 63 | #[derive(Clone, Debug, PartialEq, EnumString, IntoStaticStr)] 64 | #[strum(serialize_all = "snake_case")] 65 | pub enum AnimeMediaType { 66 | Unknown, 67 | #[strum(serialize = "tv")] 68 | TV, 69 | #[strum(serialize = "ova")] 70 | OVA, 71 | Movie, 72 | Special, 73 | #[strum(serialize = "ona")] 74 | ONA, 75 | Music, 76 | Other(String), 77 | } 78 | 79 | #[derive(Clone, Debug, PartialEq, EnumString, IntoStaticStr)] 80 | #[strum(serialize_all = "snake_case")] 81 | pub enum AnimeStatus { 82 | FinishedAiring, 83 | CurrentlyAiring, 84 | NotYetAired, 85 | Other(String), 86 | } 87 | 88 | #[derive(Clone, Debug, PartialEq, EnumString, IntoStaticStr, Display)] 89 | #[strum(serialize_all = "snake_case")] 90 | pub enum Source { 91 | Other, 92 | Original, 93 | Manga, 94 | #[strum(serialize = "4_koma_manga")] 95 | YonKomaManga, 96 | WebManga, 97 | DigitalManga, 98 | Novel, 99 | LightNovel, 100 | VisualNovel, 101 | Game, 102 | CardGame, 103 | Book, 104 | PictureBook, 105 | Radio, 106 | Music, 107 | } 108 | 109 | #[derive(Clone, Debug, Deserialize, Serialize)] 110 | pub struct UserAnimeListStatus { 111 | pub status: UserWatchStatus, 112 | pub score: u8, 113 | pub num_episodes_watched: u64, 114 | pub is_rewatching: bool, 115 | pub start_date: Option, 116 | pub finish_date: Option, 117 | pub priority: Option, 118 | pub num_times_rewatched: Option, 119 | pub rewatch_value: Option, 120 | pub tags: Option>, 121 | pub comments: Option, 122 | pub updated_at: DateTimeWrapper, 123 | } 124 | 125 | #[derive(Clone, Debug, EnumString, IntoStaticStr)] 126 | pub enum Rating { 127 | G, 128 | #[strum(serialize = "pg")] 129 | PG, 130 | #[strum(serialize = "pg_13")] 131 | PG13, 132 | R, 133 | #[strum(serialize = "r+")] 134 | Rp, 135 | #[strum(serialize = "rx")] 136 | RX, 137 | } 138 | 139 | #[derive(Clone, Debug, Deserialize, Serialize)] 140 | pub struct Anime { 141 | pub id: u64, 142 | pub title: String, 143 | pub main_picture: Option, 144 | pub alternative_titles: Option, 145 | pub start_date: Option, 146 | pub end_date: Option, 147 | pub synopsis: Option, 148 | pub mean: Option, 149 | pub rank: Option, 150 | pub popularity: Option, 151 | pub num_list_users: Option, 152 | pub num_scoring_users: Option, 153 | pub nsfw: Option, 154 | pub genres: Option>, 155 | pub created_at: Option, 156 | pub updated_at: Option, 157 | pub media_type: Option, 158 | pub status: Option, 159 | pub my_list_status: Option, 160 | pub num_episodes: Option, 161 | pub start_season: Option, 162 | pub broadcast: Option, 163 | pub source: Option, 164 | pub average_episode_duration: Option, 165 | pub rating: Option, 166 | pub studios: Option>, 167 | pub pictures: Option>, 168 | pub background: Option, 169 | pub related_anime: Option>, 170 | pub related_manga: Option>, 171 | pub recommendations: Option>, 172 | pub statistics: Option, 173 | } 174 | 175 | #[derive(Clone, Debug, Deserialize, Serialize)] 176 | pub struct AnimeRecommendation { 177 | pub node: Anime, 178 | pub num_recommendations: u64, 179 | } 180 | #[derive(Clone, Debug, Serialize, Deserialize)] 181 | pub struct RelatedAnime { 182 | pub node: Anime, 183 | pub relation_type: RelationType, 184 | pub relation_type_formatted: String, 185 | } 186 | 187 | #[derive(Debug, Clone, Deserialize, Serialize)] 188 | pub struct StartSeason { 189 | pub season: Season, 190 | pub year: u16, 191 | } 192 | 193 | #[derive(Clone, Debug, PartialEq, EnumString, IntoStaticStr)] 194 | #[strum(serialize_all = "snake_case")] 195 | pub enum RelationType { 196 | Sequel, 197 | Prequel, 198 | AlternativeSetting, 199 | AlternativeVersion, 200 | SideStory, 201 | ParentStory, 202 | Summary, 203 | FullStory, 204 | #[strum(serialize = "other")] 205 | Other(String), 206 | } 207 | 208 | #[derive(Clone, Debug, PartialEq, EnumString, IntoStaticStr, Display)] 209 | #[strum(serialize_all = "snake_case")] 210 | pub enum AnimeRankingType { 211 | All, 212 | Airing, 213 | Upcoming, 214 | #[strum(serialize = "tv")] 215 | TV, 216 | #[strum(serialize = "ova")] 217 | OVA, 218 | Movie, 219 | Special, 220 | #[strum(serialize = "popularity")] 221 | ByPopularity, 222 | Favorite, 223 | Other(String), 224 | } 225 | 226 | #[derive(Clone, Debug, Serialize, Deserialize)] 227 | pub struct RankingAnimePair { 228 | pub node: Anime, 229 | pub ranking: RankingInfo, 230 | } 231 | 232 | #[derive(Clone, Debug, PartialEq, Display, EnumString, EnumIter, IntoStaticStr)] 233 | #[strum(serialize_all = "snake_case")] 234 | pub enum UserWatchStatus { 235 | Watching, 236 | Completed, 237 | OnHold, 238 | Dropped, 239 | PlanToWatch, 240 | #[strum(serialize = "add")] 241 | Other(String), 242 | } 243 | 244 | #[derive(Clone, Debug, PartialEq, EnumString, IntoStaticStr)] 245 | #[strum(serialize_all = "snake_case")] 246 | pub enum SortStyle { 247 | ListScore, 248 | ListUpdatedAt, 249 | AnimeTitle, 250 | AnimeStartDate, 251 | AnimeId, 252 | Other(String), 253 | } 254 | 255 | impl Anime { 256 | pub fn get_title(&self, app_config: &AppConfig, both: bool) -> Vec { 257 | if both { 258 | vec![ 259 | self.title.clone(), 260 | self.alternative_titles 261 | .as_ref() 262 | .and_then(|alternative_titles| alternative_titles.en.clone()) 263 | .unwrap_or_else(|| self.title.clone()), 264 | ] 265 | } else { 266 | match app_config.title_language { 267 | TitleLanguage::Japanese => vec![self.title.clone()], 268 | TitleLanguage::English => { 269 | if let Some(ref alternative_titles) = self.alternative_titles { 270 | if let Some(ref en) = alternative_titles.en { 271 | if !en.is_empty() { 272 | vec![en.clone()] 273 | } else { 274 | vec![self.title.clone()] 275 | } 276 | } else { 277 | vec![self.title.clone()] 278 | } 279 | } else { 280 | vec![self.title.clone()] 281 | } 282 | } 283 | } 284 | } 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /src/api/model/manga.rs: -------------------------------------------------------------------------------- 1 | use crate::config::app_config::{AppConfig, MangaDisplayType, TitleLanguage}; 2 | 3 | use super::*; 4 | use serde::{Deserialize, Serialize}; 5 | use std::fmt::Debug; 6 | use strum_macros::{Display, EnumIter, EnumString, IntoStaticStr}; 7 | 8 | #[derive(Clone, Debug, PartialEq, EnumString, IntoStaticStr, Display)] 9 | #[strum(serialize_all = "snake_case")] 10 | pub enum MangaRankingType { 11 | All, 12 | Manga, 13 | Novels, 14 | #[strum(serialize = "oneshots")] 15 | OneShots, 16 | Doujinshi, 17 | Manhwa, 18 | Manhua, 19 | #[strum(serialize = "bypopularity")] 20 | ByPopularity, 21 | Favorite, 22 | Other(String), 23 | } 24 | 25 | #[derive(Clone, Debug, Serialize, Deserialize)] 26 | pub struct RankingMangaPair { 27 | pub node: Manga, 28 | pub ranking: RankingInfo, 29 | } 30 | 31 | #[derive(Clone, Debug, PartialEq, EnumString, IntoStaticStr)] 32 | #[strum(serialize_all = "snake_case")] 33 | pub enum MangaMediaType { 34 | Unknown, 35 | Manga, 36 | Novel, 37 | OneShot, 38 | Doujinshi, 39 | Manhwa, 40 | Manhua, 41 | #[strum(serialize = "oel")] 42 | OEL, 43 | Other(String), 44 | } 45 | 46 | #[derive(Clone, Debug, PartialEq, EnumString, IntoStaticStr)] 47 | #[strum(serialize_all = "snake_case")] 48 | pub enum MangaStatus { 49 | Finished, 50 | CurrentlyPublishing, 51 | NotYetPublished, 52 | Other(String), 53 | } 54 | 55 | #[derive(Clone, Debug, PartialEq, EnumString, Display, EnumIter, IntoStaticStr)] 56 | #[strum(serialize_all = "snake_case")] 57 | pub enum UserReadStatus { 58 | Reading, 59 | Completed, 60 | OnHold, 61 | Dropped, 62 | PlanToRead, 63 | #[strum(serialize = "add")] 64 | Other(String), 65 | } 66 | 67 | #[derive(Clone, Debug, Deserialize, Serialize)] 68 | pub struct UserMangaListStatus { 69 | pub status: UserReadStatus, 70 | pub score: u8, 71 | pub num_volumes_read: u64, 72 | pub num_chapters_read: u64, 73 | pub is_rereading: bool, 74 | pub start_date: Option, 75 | pub finish_date: Option, 76 | pub priority: Option, 77 | pub num_times_reread: Option, 78 | pub reread_value: Option, 79 | pub tags: Option>, 80 | pub comments: Option, 81 | pub updated_at: DateTimeWrapper, 82 | } 83 | 84 | #[derive(Clone, Debug, Deserialize, Serialize)] 85 | pub struct Manga { 86 | pub id: u64, 87 | pub title: String, 88 | pub main_picture: Option, 89 | pub alternative_titles: Option, 90 | pub start_date: Option, 91 | pub end_date: Option, 92 | pub synopsis: Option, 93 | pub background: Option, 94 | pub mean: Option, 95 | pub rank: Option, 96 | pub popularity: Option, 97 | pub num_list_users: Option, 98 | pub num_scoring_users: Option, 99 | pub nsfw: Option, 100 | pub genres: Option>, 101 | pub created_at: Option, 102 | pub updated_at: Option, 103 | pub media_type: Option, 104 | pub status: Option, 105 | pub my_list_status: Option, 106 | pub num_volumes: Option, 107 | pub num_chapters: Option, 108 | pub authors: Option>, 109 | pub related_anime: Option>, 110 | pub related_manga: Option>, 111 | pub recommendations: Option>, 112 | pub serialization: Option>, 113 | } 114 | 115 | #[derive(Clone, Debug, Serialize, Deserialize)] 116 | pub struct Serialization { 117 | pub node: Magasine, 118 | pub role: String, 119 | } 120 | 121 | #[derive(Clone, Debug, Serialize, Deserialize)] 122 | pub struct Magasine { 123 | pub id: u64, 124 | pub name: String, 125 | } 126 | 127 | #[derive(Clone, Debug, Serialize, Deserialize)] 128 | pub struct RelatedManga { 129 | pub node: Manga, 130 | pub relation_type: String, 131 | pub relation_type_formatted: String, 132 | } 133 | 134 | #[derive(Clone, Debug, Serialize, Deserialize)] 135 | pub struct MangaRecommendation { 136 | pub node: Manga, 137 | pub num_recommendations: u64, 138 | } 139 | 140 | impl Manga { 141 | pub fn get_title(&self, app_config: &AppConfig, both: bool) -> Vec { 142 | if both { 143 | vec![ 144 | self.title.clone(), 145 | self.alternative_titles 146 | .as_ref() 147 | .map_or("None".to_string(), |alt| { 148 | alt.clone().en.map_or("None".to_string(), |e| e) 149 | }), 150 | ] 151 | } else { 152 | match app_config.title_language { 153 | TitleLanguage::Japanese => vec![self.title.clone()], 154 | TitleLanguage::English => { 155 | if let Some(ref alternative_titles) = self.alternative_titles { 156 | if let Some(en) = &alternative_titles.en { 157 | if !en.is_empty() { 158 | vec![en.clone()] 159 | } else { 160 | vec![self.title.clone()] 161 | } 162 | } else { 163 | vec![self.title.clone()] 164 | } 165 | } else { 166 | vec![self.title.clone()] 167 | } 168 | } 169 | } 170 | } 171 | } 172 | pub fn get_num(&self, app_config: &AppConfig) -> String { 173 | match app_config.manga_display_type { 174 | MangaDisplayType::Vol => self.num_volumes.map_or("N/A vol".to_string(), |n| { 175 | if n == 0 { 176 | "N/A".to_string() 177 | } else { 178 | format!("{} vol", n) 179 | } 180 | }), 181 | MangaDisplayType::Ch => self.num_chapters.map_or("N/A ch".to_string(), |n| { 182 | if n == 0 { 183 | "N/A".to_string() 184 | } else { 185 | format!("{} ch", n) 186 | } 187 | }), 188 | MangaDisplayType::Both => format!( 189 | "{}, {}", 190 | self.num_volumes 191 | .map_or("N/A vol".to_string(), |n| if n == 0 { 192 | "N/A".to_string() 193 | } else { 194 | format!("{} vol", n) 195 | }), 196 | self.num_chapters 197 | .map_or("N/A ch".to_string(), |n| if n == 0 { 198 | "N/A".to_string() 199 | } else { 200 | format!("{} ch", n) 201 | }) 202 | ), 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/api/model/user.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use serde::{Deserialize, Serialize}; 3 | use std::fmt::Debug; 4 | 5 | #[derive(Clone, Debug, Deserialize, Serialize)] 6 | pub struct AnimeStatistics { 7 | pub num_items_watching: u64, 8 | pub num_items_completed: u64, 9 | pub num_items_on_hold: u64, 10 | pub num_items_dropped: u64, 11 | pub num_items_plan_to_watch: u64, 12 | pub num_items: u64, 13 | 14 | pub num_days_watched: f64, 15 | pub num_days_watching: f64, 16 | pub num_days_completed: f64, 17 | pub num_days_on_hold: f64, 18 | pub num_days_dropped: f64, 19 | pub num_days: f64, 20 | 21 | pub num_episodes: u64, 22 | pub num_times_rewatched: u64, 23 | pub mean_score: f64, 24 | } 25 | 26 | #[derive(Clone, Debug, Deserialize, Serialize)] 27 | pub struct UserInfo { 28 | pub id: u64, 29 | pub name: String, 30 | pub picture: Option, 31 | pub gender: Option, 32 | pub birthday: Option, 33 | pub location: Option, 34 | pub joined_at: DateTimeWrapper, 35 | pub anime_statistics: Option, 36 | pub time_zone: Option, 37 | pub is_supporter: Option, 38 | } 39 | -------------------------------------------------------------------------------- /src/api/user.rs: -------------------------------------------------------------------------------- 1 | use super::model::*; 2 | use super::Error; 3 | use super::{get, handle_response, API_URL}; 4 | use crate::auth::OAuth; 5 | use serde::Serialize; 6 | 7 | #[derive(Clone, Debug, Serialize)] 8 | pub struct GetUserInformationQuery { 9 | pub fields: Option, 10 | } 11 | 12 | pub async fn get_my_user_information( 13 | user: U, 14 | query: &GetUserInformationQuery, 15 | auth: &OAuth, 16 | ) -> Result { 17 | let response = get( 18 | &format!( 19 | "{}/users/{}?{}", 20 | API_URL, 21 | user.to_string(), 22 | serde_urlencoded::to_string(query)? 23 | ), 24 | auth, 25 | ) 26 | .await?; 27 | handle_response(&response) 28 | } 29 | 30 | #[cfg(test)] 31 | mod tests { 32 | use super::*; 33 | 34 | #[tokio::test] 35 | async fn test_get_user_information() { 36 | let auth = crate::auth::tests::get_auth(); 37 | let query = GetUserInformationQuery { 38 | fields: Some(ALL_USER_FIELDS.to_string()), 39 | }; 40 | let result = get_my_user_information("@me", &query, &auth).await.unwrap(); 41 | println!("{:#?}", result); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/auth/cache.rs: -------------------------------------------------------------------------------- 1 | use super::OAuth; 2 | use crate::config::oauth_config::AuthConfig; 3 | use std::{ 4 | fs::OpenOptions, 5 | io::{Read, Write}, 6 | }; 7 | 8 | pub fn cache_auth(auth: &OAuth) { 9 | let auth_path = AuthConfig::get_paths().unwrap().auth_cache_path; 10 | 11 | let mut auth_file = OpenOptions::new() 12 | .write(true) 13 | .create(true) 14 | .truncate(false) 15 | .open(auth_path) 16 | .unwrap(); 17 | 18 | let cached_auth = serde_json::to_string(auth).unwrap(); 19 | 20 | write!(auth_file, "{}", cached_auth).unwrap(); 21 | } 22 | 23 | pub fn load_cached_auth() -> Option { 24 | let auth_path = AuthConfig::get_paths().unwrap().auth_cache_path; 25 | let mut auth_file = OpenOptions::new() 26 | .read(true) 27 | .write(true) 28 | .create(true) 29 | .truncate(false) 30 | .open(auth_path) 31 | .unwrap(); 32 | 33 | let mut cached_string = String::new(); 34 | 35 | auth_file.read_to_string(&mut cached_string).unwrap(); 36 | 37 | let cached_auth: OAuth = match serde_json::from_str(&cached_string) { 38 | Ok(s) => s, 39 | Err(_) => return None, 40 | }; 41 | 42 | Some(cached_auth) 43 | } 44 | 45 | pub fn delete_cached_auth() { 46 | let auth_path = AuthConfig::get_paths().unwrap().config_file_path; 47 | let _ = std::fs::remove_file(auth_path); 48 | } 49 | -------------------------------------------------------------------------------- /src/auth/redirect.rs: -------------------------------------------------------------------------------- 1 | /// HTTP server on host system 2 | /// ex. 127.0.0.1:7878 3 | /// blocks until one request is recieved (auth redirect) and parses it to get the code 4 | pub struct Server { 5 | auth: super::OAuth, 6 | app_name: String, 7 | } 8 | 9 | /// Error type for server methods 10 | #[derive(Debug)] 11 | pub enum ServerError { 12 | IOError(std::io::Error), 13 | HTTParseError(httparse::Error), 14 | InvalidRequestURL(String), 15 | AuthError(super::AuthError), 16 | } 17 | 18 | impl From for ServerError { 19 | fn from(e: std::io::Error) -> Self { 20 | ServerError::IOError(e) 21 | } 22 | } 23 | 24 | impl From for ServerError { 25 | fn from(e: httparse::Error) -> Self { 26 | ServerError::HTTParseError(e) 27 | } 28 | } 29 | 30 | impl From for ServerError { 31 | fn from(e: super::AuthError) -> Self { 32 | ServerError::AuthError(e) 33 | } 34 | } 35 | 36 | impl Server { 37 | /// Create the server 38 | pub fn new(app_name: A, auth: super::OAuth) -> Self { 39 | Server { 40 | auth, 41 | app_name: app_name.to_string(), 42 | } 43 | } 44 | 45 | /// Run the server. 46 | /// Blocks until it recieves exactly one response. 47 | pub fn go(self) -> Result { 48 | use std::io::prelude::*; 49 | use std::net::TcpListener; 50 | 51 | let listener = TcpListener::bind(&self.auth.redirect_url)?; 52 | let mut socket_stream = listener.incoming().next().unwrap()?; 53 | 54 | // read all bytes of the request 55 | let mut request_bytes = Vec::new(); 56 | loop { 57 | const BUF_SIZE: usize = 4096; 58 | let mut buf: [u8; BUF_SIZE] = [0; BUF_SIZE]; 59 | match socket_stream.read(&mut buf) { 60 | Ok(val) => { 61 | if val > 0 { 62 | request_bytes.append(&mut Vec::from(&buf[0..val])); 63 | if val < BUF_SIZE { 64 | break; 65 | } 66 | } else { 67 | break; 68 | } 69 | } 70 | Err(e) => return Err(ServerError::IOError(e)), 71 | }; 72 | } 73 | 74 | let mut headers = [httparse::EMPTY_HEADER; 16]; 75 | let mut parsed_request = httparse::Request::new(&mut headers); 76 | 77 | parsed_request.parse(&request_bytes)?; 78 | 79 | let raw_url = if let Some(path) = parsed_request.path { 80 | format!("http://{}{}", self.auth.redirect_url, path) 81 | } else { 82 | return Err(ServerError::InvalidRequestURL("".to_string())); 83 | }; 84 | 85 | let parsed_url = match url::Url::parse(&raw_url) { 86 | Ok(url) => url, 87 | Err(_) => return Err(ServerError::InvalidRequestURL(raw_url)), 88 | }; 89 | 90 | let query = if let Some(query) = parsed_url.query() { 91 | query 92 | } else { 93 | return Err(ServerError::InvalidRequestURL( 94 | "No query string".to_string(), 95 | )); 96 | }; 97 | 98 | let mut ret_auth = self.auth; 99 | 100 | ret_auth.parse_redirect_query_string(query)?; 101 | 102 | // return a minimal http response to the browser 103 | let r = format!("HTTP/1.1 200 OK\r\n\r\n{} Authorized{} Authorized", self.app_name, self.app_name); 104 | socket_stream.write_all(r.as_bytes())?; 105 | socket_stream.flush()?; 106 | 107 | Ok(ret_auth) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/auth/token.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::time; 3 | 4 | /// An Authorization Token 5 | #[derive(Debug, Clone, Serialize, Deserialize)] 6 | pub struct Token { 7 | /// Token Type 8 | pub token_type: String, 9 | /// When the token will expire relative to when it was created in seconds 10 | pub expires_in: u64, 11 | /// Access token for api requests 12 | pub access_token: String, 13 | /// Refresh token for refreshing the access token when it expires 14 | pub refresh_token: String, 15 | } 16 | 17 | /// Holds token and timestamp 18 | #[derive(Debug, Clone, Serialize, Deserialize)] 19 | pub struct TokenWrapper { 20 | /// The token 21 | pub token: Token, 22 | /// The time that the token was generated 23 | pub generate_time: u64, 24 | } 25 | 26 | impl TokenWrapper { 27 | /// Returns seconds since the unix epoch 28 | fn sec_since_epoch() -> u64 { 29 | time::SystemTime::now() 30 | .duration_since(time::UNIX_EPOCH) 31 | .unwrap() 32 | .as_secs() 33 | } 34 | /// Creates a new TokenWrapper 35 | pub fn new(token: Token) -> Self { 36 | TokenWrapper { 37 | token, 38 | generate_time: Self::sec_since_epoch(), 39 | } 40 | } 41 | /// Check if the token is expired 42 | pub fn expired(&self) -> bool { 43 | let now = Self::sec_since_epoch(); 44 | now >= self.generate_time + self.token.expires_in 45 | } 46 | 47 | /// Get seconds until expiry (None if already expired) 48 | pub fn expires_in_secs(&self) -> Option { 49 | let now = Self::sec_since_epoch(); 50 | let expires_in = self.generate_time + self.token.expires_in; 51 | if now >= expires_in { 52 | None 53 | } else { 54 | Some(expires_in - now) 55 | } 56 | } 57 | /// Get the time that the token will expire (None if already expired) 58 | pub fn expire_time(&self) -> Option { 59 | self.expires_in_secs() 60 | .map(|secs| time::SystemTime::now() + time::Duration::from_secs(secs)) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use figlet_rs::FIGfont; 3 | #[derive(Debug, Parser)] 4 | #[command(name = "mal", version, about = "A TUI client for myanimelist.net", long_about = None)] 5 | struct Cli { 6 | /// Show extra information about the tool 7 | #[arg(short = 'i', long = "info", action = clap::ArgAction::SetTrue)] 8 | info: bool, 9 | /// Show configuration file structure and all available options 10 | #[arg(short = 'c', long = "config", action = clap::ArgAction::SetTrue)] 11 | config: bool, 12 | } 13 | 14 | pub fn handle_args() -> bool { 15 | let cli = Cli::parse(); 16 | 17 | if cli.info { 18 | print_info(); 19 | return true; 20 | } else if cli.config { 21 | print_config_structure(); 22 | return true; 23 | } 24 | false 25 | } 26 | 27 | fn print_info() { 28 | let standard_font = FIGfont::standard().unwrap(); 29 | let figlet = standard_font.convert("mal-cli"); 30 | let fig_string = figlet.unwrap().to_string(); 31 | println!( 32 | r#" 33 | {fig_string} 34 | 35 | FILES: 36 | - OAuth2 tokens: $HOME/.config/mal-cli/oauth2.yml 37 | - Cache data: $HOME/.cache/mal-cli/ 38 | - Configuration file: $HOME/.config/mal-cli/config.yml 39 | 40 | NOTE: 41 | - Use GPU-enhanced terminals, otherwise the images won't be rendered 42 | - The configuration file is optional. If it does not exist, the application will create a default one. 43 | - The cache directory is used to store images 44 | "# 45 | ); 46 | } 47 | 48 | fn print_config_structure() { 49 | println!( 50 | r#" 51 | 52 | CONFIGURATION KEYS: 53 | =================== 54 | 55 | KEYBINDINGS: 56 | keys: 57 | help: '?' # Show help menu 58 | back: 'q' # Go back/quit current view 59 | search: '/' # Open search 60 | toggle: 's' # Toggle between anime/manga or switch states 61 | next_state: Ctrl+p # Navigate to next state/page 62 | open_popup: 'r' # Open rating/status popup 63 | 64 | THEME COLORS: 65 | theme: 66 | mal_color: '#2E51A2' # MyAnimeList brand color (blue) 67 | active: Cyan # Color for active/focused elements 68 | banner: LightCyan # Color for banners and titles 69 | hovered: Magenta # Color for hovered elements 70 | text: White # Default text color 71 | selected: LightCyan # Color for selected items 72 | error_border: Red # Border color for error dialogs 73 | error_text: LightRed # Text color for error messages 74 | inactive: Gray # Color for inactive/unfocused elements 75 | status_completed: Green # Color for completed anime/manga 76 | status_dropped: Gray # Color for dropped items 77 | status_on_hold: Yellow # Color for on-hold items 78 | status_watching: Blue # Color for currently watching/reading 79 | status_plan_to_watch: Cyan # Color for plan-to-watch/read items 80 | status_other: White # Color for other status types 81 | 82 | APPLICATION BEHAVIOR: 83 | behavior: 84 | tick_rate_milliseconds: 500 # UI refresh rate (lower = more responsive) 85 | show_logger: false # Show debug logger window 86 | 87 | CONTENT SETTINGS: 88 | nsfw: false # Show NSFW (18+) content 89 | title_language: English # Preferred title language (English/Japanese) 90 | manga_display_type: Both # Show volumes, chapters, or both (Vol/Ch/Both) 91 | 92 | RANKING TYPES: 93 | top_three_anime_types: # Anime ranking types to show in top 3 94 | - airing # Currently airing anime 95 | - all # All-time rankings 96 | - upcoming # Upcoming anime 97 | - movie # Movie rankings 98 | - special # Special episodes 99 | - ova # Original Video Animation 100 | - tv # TV series 101 | - popularity # Most popular 102 | - favorite # Most favorited 103 | 104 | top_three_manga_types: # Manga ranking types to show in top 3 105 | - all # All-time rankings 106 | - manga # Manga only 107 | - novels # Light novels 108 | - oneshots # One-shot manga 109 | - doujinshi # Self-published works 110 | - manhwa # Korean comics 111 | - manhua # Chinese comics 112 | - bypopularity # Most popular 113 | - favorite # Most favorited 114 | 115 | PERFORMANCE SETTINGS: 116 | navigation_stack_limit: 15 # Max number of pages to keep in history 117 | search_limit: 30 # Max search results per page 118 | max_cached_images: 15 # Max images to cache for faster loading 119 | 120 | EXAMPLE CONFIG FILE: 121 | ==================== 122 | Copy the example configuration from: config.example.yml 123 | Location: $HOME/.config/mal-cli/config.yml 124 | "# 125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /src/config/app_config.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use super::*; 7 | use crate::{ 8 | api::model::{AnimeRankingType, MangaRankingType}, 9 | event::key::Key, 10 | }; 11 | use log::LevelFilter; 12 | use ratatui::style::Color; 13 | use serde::{Deserialize, Serialize}; 14 | 15 | #[derive(Clone, Deserialize, Serialize)] 16 | pub struct AppConfig { 17 | #[serde(skip_deserializing, skip_serializing)] 18 | pub paths: CachePaths, 19 | pub keys: KeyBindings, 20 | pub theme: Theme, 21 | pub behavior: BehaviorConfig, 22 | pub nsfw: bool, 23 | pub title_language: TitleLanguage, 24 | pub manga_display_type: MangaDisplayType, 25 | // pub first_top_three_block: TopThreeBlock, 26 | pub top_three_anime_types: Vec, 27 | pub top_three_manga_types: Vec, 28 | pub navigation_stack_limit: u32, 29 | pub search_limit: u64, 30 | pub log_level: LevelFilter, 31 | pub max_cached_images: u16, 32 | } 33 | 34 | #[derive(Clone, Deserialize, Serialize, Debug)] 35 | pub enum TitleLanguage { 36 | Japanese, 37 | English, 38 | } 39 | 40 | #[derive(Copy, Deserialize, Serialize, Clone, Debug)] 41 | pub struct Theme { 42 | pub mal_color: Color, 43 | pub active: Color, 44 | pub banner: Color, 45 | pub hovered: Color, 46 | pub text: Color, 47 | pub selected: Color, 48 | pub error_border: Color, 49 | pub error_text: Color, 50 | pub inactive: Color, 51 | pub status_completed: Color, 52 | pub status_dropped: Color, 53 | pub status_on_hold: Color, 54 | pub status_watching: Color, 55 | pub status_plan_to_watch: Color, 56 | pub status_other: Color, 57 | } 58 | 59 | impl Default for Theme { 60 | fn default() -> Self { 61 | Self { 62 | mal_color: Color::Rgb(46, 81, 162), 63 | active: Color::Cyan, 64 | banner: Color::Rgb(46, 81, 162), 65 | hovered: Color::Magenta, 66 | selected: Color::LightCyan, 67 | text: Color::White, 68 | error_border: Color::Red, 69 | error_text: Color::LightRed, 70 | inactive: Color::Gray, 71 | status_completed: Color::Green, 72 | status_dropped: Color::Gray, 73 | status_on_hold: Color::Yellow, 74 | status_watching: Color::Blue, 75 | status_plan_to_watch: Color::LightMagenta, 76 | status_other: Color::DarkGray, 77 | } 78 | } 79 | } 80 | 81 | #[derive(Clone, Deserialize, Serialize)] 82 | pub struct KeyBindings { 83 | pub help: Key, 84 | pub back: Key, 85 | pub search: Key, 86 | pub toggle: Key, 87 | pub next_state: Key, 88 | pub open_popup: Key, 89 | } 90 | 91 | #[derive(Clone, Deserialize, Serialize)] 92 | pub struct BehaviorConfig { 93 | // pub show_loading_indicator: bool, 94 | // pub seek_milliseconds: u64, 95 | pub tick_rate_milliseconds: u64, 96 | pub show_logger: bool, 97 | } 98 | 99 | #[derive(Clone, Debug, Deserialize, Serialize)] 100 | pub enum MangaDisplayType { 101 | Vol, 102 | Ch, 103 | Both, 104 | } 105 | 106 | impl AppConfig { 107 | pub fn new() -> Result { 108 | let paths = get_cache_dir()?; 109 | 110 | Ok(Self { 111 | paths, 112 | theme: Theme::default(), 113 | keys: KeyBindings { 114 | help: Key::Char('?'), 115 | back: Key::Char('q'), 116 | search: Key::Char('/'), 117 | toggle: Key::Char('s'), 118 | open_popup: Key::Char('r'), 119 | next_state: Key::Ctrl('p'), 120 | }, 121 | behavior: BehaviorConfig { 122 | tick_rate_milliseconds: 500, 123 | show_logger: false, 124 | }, 125 | nsfw: false, 126 | title_language: TitleLanguage::English, 127 | manga_display_type: MangaDisplayType::Both, 128 | top_three_anime_types: vec![ 129 | AnimeRankingType::Airing, 130 | AnimeRankingType::All, 131 | AnimeRankingType::Upcoming, 132 | AnimeRankingType::Movie, 133 | AnimeRankingType::Special, 134 | AnimeRankingType::OVA, 135 | AnimeRankingType::TV, 136 | AnimeRankingType::ByPopularity, 137 | AnimeRankingType::Favorite, 138 | ], 139 | top_three_manga_types: vec![ 140 | MangaRankingType::All, 141 | MangaRankingType::Manga, 142 | MangaRankingType::Novels, 143 | MangaRankingType::OneShots, 144 | MangaRankingType::Doujinshi, 145 | MangaRankingType::Manhwa, 146 | MangaRankingType::Manhua, 147 | MangaRankingType::ByPopularity, 148 | MangaRankingType::Favorite, 149 | ], 150 | navigation_stack_limit: 15, 151 | search_limit: 30, 152 | max_cached_images: 15, 153 | log_level: LevelFilter::Debug, 154 | }) 155 | } 156 | 157 | pub fn load() -> Result { 158 | // check file exists 159 | // do not get paths from config file,always use the default paths 160 | let config_file = dirs::home_dir() 161 | .ok_or(ConfigError::PathError)? 162 | .join(CONFIG_DIR) 163 | .join(APP_CONFIG_DIR) 164 | .join(_CONFIG_FILE); 165 | if !config_file.exists() { 166 | // if config file doesn't exist, create default config 167 | fs::create_dir_all(config_file.parent().unwrap())?; 168 | let default_config = Self::new()?; 169 | 170 | fs::write(&config_file, serde_yaml::to_string(&default_config)?)?; 171 | Ok(default_config) 172 | } else { 173 | // if config file exists, read it 174 | let content = fs::read_to_string(&config_file).map_err(|_| ConfigError::ReadError)?; 175 | let config: Self = serde_yaml::from_str(&content).map_err(ConfigError::ParseError)?; 176 | 177 | Ok(config) 178 | } 179 | } 180 | } 181 | 182 | fn get_cache_dir() -> Result { 183 | match dirs::home_dir() { 184 | Some(home) => { 185 | let path = Path::new(&home); 186 | 187 | // cache dir: 188 | let home_cache_dir = path.join(CACHE_DIR); 189 | 190 | let cache_dir = home_cache_dir.join(APP_CACHE_DIR); 191 | 192 | let picture_cache_dir = cache_dir.join(PICTURE_CACHE_DIR); 193 | 194 | let data_file_path = cache_dir.join(DATA_FILE); 195 | 196 | if !home_cache_dir.exists() { 197 | fs::create_dir(&home_cache_dir)?; 198 | } 199 | if !cache_dir.exists() { 200 | fs::create_dir(&cache_dir)?; 201 | } 202 | 203 | if !picture_cache_dir.exists() { 204 | fs::create_dir(&picture_cache_dir)?; 205 | } 206 | 207 | let paths = CachePaths { 208 | picture_cache_dir_path: picture_cache_dir.to_path_buf(), 209 | data_file_path, 210 | }; 211 | 212 | Ok(paths) 213 | } 214 | None => Err(ConfigError::PathError), 215 | } 216 | } 217 | 218 | #[derive(Clone, Debug, Deserialize, Serialize)] 219 | pub struct CachePaths { 220 | pub picture_cache_dir_path: PathBuf, 221 | pub data_file_path: PathBuf, 222 | } 223 | impl Default for CachePaths { 224 | fn default() -> Self { 225 | get_cache_dir().ok().unwrap() 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | // auth config 2 | pub mod oauth_config; 3 | 4 | // app config 5 | pub mod app_config; 6 | 7 | // pub use app_config::AppConfig; 8 | pub use oauth_config::AuthConfig; 9 | 10 | use std::path::PathBuf; 11 | 12 | const CONFIG_DIR: &str = ".config"; 13 | const APP_CONFIG_DIR: &str = "mal-cli"; 14 | const CACHE_DIR: &str = ".cache"; 15 | const APP_CACHE_DIR: &str = "mal-cli"; 16 | const PICTURE_CACHE_DIR: &str = "images"; 17 | const DATA_FILE: &str = "mal_data.json"; 18 | 19 | const DEFAULT_PORT: u16 = 2006; 20 | const DEFAULT_USER_AGENT: &str = "mal-cli"; 21 | const OAUTH_FILE: &str = "oauth2.yml"; 22 | const TOKEN_CACHE_FILE: &str = ".mal_token_cache.json"; 23 | 24 | const _CONFIG_FILE: &str = "config.yml"; 25 | 26 | #[derive(Debug)] 27 | pub enum ConfigError { 28 | /// Represents an invalid config file 29 | EmptyConfig, 30 | /// Represents a failure to read from input 31 | ReadError, 32 | /// Represents a nonexistent path error 33 | PathError, 34 | /// Represents a serde_yaml parse error 35 | ParseError(serde_yaml::Error), 36 | /// Represents all other failures 37 | IOError(std::io::Error), 38 | 39 | InvalidClientIdError, 40 | } 41 | 42 | impl std::error::Error for ConfigError { 43 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 44 | match *self { 45 | ConfigError::EmptyConfig => None, 46 | ConfigError::ReadError => None, 47 | ConfigError::PathError => None, 48 | ConfigError::ParseError(_) => None, 49 | ConfigError::IOError(_) => None, 50 | ConfigError::InvalidClientIdError => None, 51 | } 52 | } 53 | } 54 | 55 | impl std::fmt::Display for ConfigError { 56 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 57 | match *self { 58 | ConfigError::EmptyConfig => write!(f, "Source contains no data"), 59 | ConfigError::ReadError => write!(f, "Could not read file"), 60 | ConfigError::PathError => write!(f, "Path not found"), 61 | ConfigError::ParseError(ref err) => err.fmt(f), 62 | ConfigError::IOError(ref err) => err.fmt(f), 63 | ConfigError::InvalidClientIdError => write!(f, "Invalid client ID provided"), 64 | } 65 | } 66 | } 67 | 68 | impl From for ConfigError { 69 | fn from(err: std::io::Error) -> Self { 70 | ConfigError::IOError(err) 71 | } 72 | } 73 | 74 | impl From for ConfigError { 75 | fn from(err: serde_yaml::Error) -> Self { 76 | ConfigError::ParseError(err) 77 | } 78 | } 79 | 80 | pub struct ConfigPaths { 81 | pub config_file_path: PathBuf, 82 | pub auth_cache_path: PathBuf, 83 | } 84 | -------------------------------------------------------------------------------- /src/config/oauth_config.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use figlet_rs::FIGfont; 3 | use serde::{Deserialize, Serialize}; 4 | use serde_yaml; 5 | use std::{ 6 | fs, 7 | io::{stdin, Write}, 8 | path::Path, 9 | }; 10 | #[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq)] 11 | pub struct AuthConfig { 12 | pub client_id: String, 13 | pub user_agent: Option, 14 | pub port: Option, 15 | } 16 | 17 | impl AuthConfig { 18 | // TODO: Strip whitespace from user_agent as it can cause code to panic 19 | pub fn load() -> Result { 20 | let paths = Self::get_paths()?; 21 | if paths.config_file_path.exists() { 22 | let config_string = fs::read_to_string(&paths.config_file_path)?; 23 | let config_yml: AuthConfig = serde_yaml::from_str(&config_string)?; 24 | 25 | Ok(config_yml) 26 | } else { 27 | let standard_font = FIGfont::standard().unwrap(); 28 | let figlet = standard_font.convert("MAL-CLI"); 29 | let banner = figlet.unwrap().to_string(); 30 | println!("{}", banner); 31 | println!( 32 | "Config will be saved to {}", 33 | paths.config_file_path.display() 34 | ); 35 | 36 | println!("\nHow to get setup:\n"); 37 | 38 | let instructions = [ 39 | "Go to the myanimelist api page - https://myanimelist.net/apiconfig", 40 | "Click `Create ID` and create an app", 41 | &format!( 42 | "Add `http://127.0.0.1:{}` to the Redirect URIs", 43 | DEFAULT_PORT 44 | ), 45 | "You are now ready to authenticate with myanimelist!", 46 | ]; 47 | 48 | let mut number = 1; 49 | for item in instructions.iter() { 50 | println!(" {}. {}", number, item); 51 | number += 1; 52 | } 53 | 54 | let mut client_id = String::new(); 55 | loop { 56 | println!("\nEnter your client ID: "); 57 | stdin().read_line(&mut client_id)?; 58 | let trimmed_client_id = client_id.trim().to_string(); 59 | if trimmed_client_id.len() == 32 60 | && trimmed_client_id.chars().all(|c| c.is_ascii_hexdigit()) 61 | { 62 | client_id = trimmed_client_id; 63 | break; 64 | } else { 65 | println!("Invalid client ID format. try again"); 66 | client_id.clear(); 67 | } 68 | } 69 | let mut user_agent = String::new(); 70 | println!("\nEnter User Agent (default {}): ", DEFAULT_USER_AGENT); 71 | stdin().read_line(&mut user_agent)?; 72 | 73 | let user_agent = match user_agent.trim().len() { 74 | 0 => DEFAULT_USER_AGENT.to_string(), 75 | _ => user_agent.trim().to_string(), 76 | }; 77 | 78 | let mut port = String::new(); 79 | println!("\nEnter port of redirect uri (default {}): ", DEFAULT_PORT); 80 | stdin().read_line(&mut port)?; 81 | let port = port.trim().parse::().unwrap_or(DEFAULT_PORT); 82 | 83 | let config_yml = AuthConfig { 84 | client_id, 85 | user_agent: Some(user_agent), 86 | port: Some(port), 87 | }; 88 | 89 | let content_yml = serde_yaml::to_string(&config_yml)?; 90 | 91 | let mut new_config = fs::File::create(&paths.config_file_path)?; 92 | write!(new_config, "{}", content_yml)?; 93 | 94 | Ok(config_yml) 95 | } 96 | } 97 | 98 | pub fn get_redirect_uri(&self) -> String { 99 | format!("127.0.0.1:{}", self.get_port()) 100 | } 101 | 102 | pub fn get_port(&self) -> u16 { 103 | self.port.unwrap_or(DEFAULT_PORT) 104 | } 105 | 106 | pub fn get_user_agent(&self) -> String { 107 | match &self.user_agent { 108 | Some(s) => s.clone(), 109 | None => DEFAULT_USER_AGENT.to_string(), 110 | } 111 | } 112 | 113 | pub fn get_paths() -> Result { 114 | match dirs::home_dir() { 115 | Some(home) => { 116 | let path = Path::new(&home); 117 | let home_config_dir = path.join(CONFIG_DIR); 118 | let app_config_dir = home_config_dir.join(APP_CONFIG_DIR); 119 | 120 | if !home_config_dir.exists() { 121 | fs::create_dir(&home_config_dir)?; 122 | } 123 | 124 | if !app_config_dir.exists() { 125 | fs::create_dir(&app_config_dir)?; 126 | } 127 | 128 | let config_file_path = &app_config_dir.join(OAUTH_FILE); 129 | let token_cache_path = &app_config_dir.join(TOKEN_CACHE_FILE); 130 | 131 | let paths = ConfigPaths { 132 | config_file_path: config_file_path.to_path_buf(), 133 | auth_cache_path: token_cache_path.to_path_buf(), 134 | }; 135 | 136 | Ok(paths) 137 | } 138 | None => Err(ConfigError::PathError), 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/event/events.rs: -------------------------------------------------------------------------------- 1 | use crate::event::Key; 2 | use crossterm::event::{self, Event as CEvent}; 3 | use std::{ 4 | sync::mpsc, 5 | thread, 6 | time::{Duration, Instant}, 7 | }; 8 | 9 | #[derive(Debug, Clone, Copy)] 10 | pub struct EventConfig { 11 | pub exit_key: Key, 12 | pub tick_rate: Duration, 13 | } 14 | 15 | impl Default for EventConfig { 16 | fn default() -> EventConfig { 17 | EventConfig { 18 | exit_key: Key::Ctrl('c'), 19 | tick_rate: Duration::from_millis(250), 20 | } 21 | } 22 | } 23 | 24 | pub enum Event { 25 | Input(I), 26 | Tick, 27 | } 28 | 29 | pub struct Events { 30 | rx: mpsc::Receiver>, 31 | _tx: mpsc::Sender>, 32 | } 33 | 34 | impl Events { 35 | pub fn new(tick_rate: u64) -> Self { 36 | Events::with_config(EventConfig { 37 | tick_rate: Duration::from_millis(tick_rate), 38 | ..Default::default() 39 | }) 40 | } 41 | 42 | pub fn with_config(config: EventConfig) -> Events { 43 | let (tx, rx) = mpsc::channel(); 44 | let event_tx = tx.clone(); 45 | let tick_rate = config.tick_rate; 46 | 47 | thread::spawn(move || { 48 | let mut last_tick = Instant::now(); 49 | 50 | loop { 51 | let timeout = tick_rate 52 | .checked_sub(last_tick.elapsed()) 53 | .unwrap_or_else(|| Duration::from_secs(0)); 54 | 55 | if event::poll(timeout).unwrap() { 56 | if let CEvent::Key(key_event) = event::read().unwrap() { 57 | let key = Key::from(key_event); 58 | if event_tx.send(Event::Input(key)).is_err() { 59 | break; 60 | } 61 | } 62 | } 63 | 64 | if last_tick.elapsed() >= tick_rate { 65 | if event_tx.send(Event::Tick).is_err() { 66 | break; 67 | } 68 | last_tick = Instant::now(); 69 | } 70 | } 71 | }); 72 | 73 | Events { rx, _tx: tx } 74 | } 75 | 76 | pub fn next(&self) -> Result, mpsc::RecvError> { 77 | self.rx.recv() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/event/key.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | /// Represends a Key Press 5 | #[derive(PartialEq, Eq, Clone, Copy, Hash, Debug, Deserialize, Serialize)] 6 | #[serde(rename_all = "snake_case")] 7 | pub enum Key { 8 | Enter, 9 | Tab, 10 | BackTab, 11 | Backspace, 12 | Esc, 13 | Left, 14 | Right, 15 | Up, 16 | Down, 17 | Ins, 18 | Delete, 19 | Home, 20 | PageUp, 21 | PageDown, 22 | Char(char), 23 | Ctrl(char), 24 | Alt(char), 25 | Unknown, 26 | F0, 27 | F1, 28 | F2, 29 | F3, 30 | F4, 31 | F5, 32 | F6, 33 | F7, 34 | F8, 35 | F9, 36 | F10, 37 | F11, 38 | F12, 39 | } 40 | 41 | impl Key { 42 | /// Returns the function key corresponding to the given number 43 | /// 44 | /// 1 -> F1, etc... 45 | /// 46 | /// # Panics 47 | /// 48 | /// If `n == 0 || n > 12` 49 | pub fn from_f(n: u8) -> Key { 50 | match n { 51 | 0 => Key::F0, 52 | 1 => Key::F1, 53 | 2 => Key::F2, 54 | 3 => Key::F3, 55 | 4 => Key::F4, 56 | 5 => Key::F5, 57 | 6 => Key::F6, 58 | 7 => Key::F7, 59 | 8 => Key::F8, 60 | 9 => Key::F9, 61 | 10 => Key::F10, 62 | 11 => Key::F11, 63 | 12 => Key::F12, 64 | _ => panic!("unknown function key: F{}", n), 65 | } 66 | } 67 | } 68 | 69 | impl From for Key { 70 | fn from(key_event: KeyEvent) -> Self { 71 | match key_event { 72 | KeyEvent { 73 | code: KeyCode::Enter, 74 | .. 75 | } => Key::Enter, 76 | KeyEvent { 77 | code: KeyCode::Tab, .. 78 | } => Key::Tab, 79 | KeyEvent { 80 | code: KeyCode::BackTab, 81 | .. 82 | } => Key::BackTab, 83 | KeyEvent { 84 | code: KeyCode::Backspace, 85 | .. 86 | } => Key::Backspace, 87 | KeyEvent { 88 | code: KeyCode::Esc, .. 89 | } => Key::Esc, 90 | KeyEvent { 91 | code: KeyCode::Left, 92 | .. 93 | } => Key::Left, 94 | KeyEvent { 95 | code: KeyCode::Right, 96 | .. 97 | } => Key::Right, 98 | KeyEvent { 99 | code: KeyCode::Up, .. 100 | } => Key::Up, 101 | KeyEvent { 102 | code: KeyCode::Down, 103 | .. 104 | } => Key::Down, 105 | KeyEvent { 106 | code: KeyCode::Insert, 107 | .. 108 | } => Key::Ins, 109 | KeyEvent { 110 | code: KeyCode::Delete, 111 | .. 112 | } => Key::Delete, 113 | KeyEvent { 114 | code: KeyCode::Home, 115 | .. 116 | } => Key::Home, 117 | KeyEvent { 118 | code: KeyCode::PageUp, 119 | .. 120 | } => Key::PageUp, 121 | KeyEvent { 122 | code: KeyCode::PageDown, 123 | .. 124 | } => Key::PageDown, 125 | KeyEvent { 126 | code: KeyCode::Char(c), 127 | modifiers: KeyModifiers::ALT, 128 | .. 129 | } => Key::Alt(c), 130 | KeyEvent { 131 | code: KeyCode::Char(c), 132 | modifiers: KeyModifiers::CONTROL, 133 | .. 134 | } => Key::Ctrl(c), 135 | KeyEvent { 136 | code: KeyCode::Char(c), 137 | .. 138 | } => Key::Char(c), 139 | _ => Key::Unknown, 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/event/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod events; 2 | pub mod key; 3 | pub use self::{ 4 | events::{Event, Events}, 5 | key::Key, 6 | }; 7 | -------------------------------------------------------------------------------- /src/handlers/anime.rs: -------------------------------------------------------------------------------- 1 | use super::common; 2 | use crate::app::{ActiveDisplayBlock, App, Data, ANIME_OPTIONS, ANIME_OPTIONS_RANGE}; 3 | 4 | use crate::event::Key; 5 | use crate::network::IoEvent; 6 | 7 | pub fn handler(key: Key, app: &mut App) { 8 | match key { 9 | // k if common::right_event(k) => common::handle_right_event(app), 10 | k if common::down_event(k) => { 11 | // calculate the next index in the list 12 | let next_index = ANIME_OPTIONS_RANGE.start 13 | + common::on_down_press( 14 | &ANIME_OPTIONS, 15 | Some(app.library.selected_index % (ANIME_OPTIONS.len())), 16 | ); 17 | app.library.selected_index = next_index; 18 | } 19 | k if common::up_event(k) => { 20 | // calculate the next index in the list 21 | let next_index = ANIME_OPTIONS_RANGE.start 22 | + common::on_up_press( 23 | &ANIME_OPTIONS, 24 | Some(app.library.selected_index % (ANIME_OPTIONS.len())), 25 | ); 26 | app.library.selected_index = next_index; 27 | } 28 | 29 | Key::Enter => { 30 | match app.library.selected_index { 31 | // Seasonal 32 | 0 => get_seasonal(app), 33 | // Ranking 34 | 1 => get_anime_ranking(app), 35 | // Suggested 36 | 2 => get_suggestion(app), 37 | // This is required because Rust can't tell if this pattern in exhaustive 38 | _ => {} 39 | }; 40 | app.library.selected_index = 9; 41 | } 42 | 43 | _ => (), 44 | }; 45 | } 46 | 47 | fn get_seasonal(app: &mut App) { 48 | let (is_data_availabe, is_next, index) = is_seasonal_data_available(app); 49 | let is_current_route = app 50 | .get_current_route() 51 | .is_some_and(|r| r.block == ActiveDisplayBlock::Seasonal); 52 | 53 | if is_current_route { 54 | return; 55 | } 56 | app.reset_result_index(); 57 | 58 | if is_next { 59 | app.load_next_route(); 60 | return; 61 | } 62 | 63 | if is_data_availabe { 64 | app.load_route(index.unwrap()); 65 | } else { 66 | app.active_display_block = ActiveDisplayBlock::Loading; 67 | 68 | app.dispatch(IoEvent::GetSeasonalAnime); 69 | } 70 | } 71 | 72 | fn is_seasonal_data_available(app: &mut App) -> (bool, bool, Option) { 73 | for i in 0..(app.navigator.history.len() - 1) { 74 | let id = app.navigator.history[i]; 75 | if app.navigator.data[&id].block == ActiveDisplayBlock::Seasonal 76 | && app.navigator.data[&id].data.is_some() 77 | { 78 | let is_next = app.navigator.index + 1 == i; 79 | return (true, is_next, Some(id)); 80 | } 81 | } 82 | (false, false, None) 83 | } 84 | 85 | pub fn get_anime_ranking(app: &mut App) { 86 | let (is_data_available, is_next, index) = is_anime_ranking_data_available(app); 87 | 88 | let is_current_route = app 89 | .get_current_route() 90 | .is_some_and(|r| r.block == ActiveDisplayBlock::AnimeRanking); 91 | 92 | if is_current_route { 93 | return; 94 | } 95 | 96 | app.reset_result_index(); 97 | if is_next { 98 | app.load_next_route(); 99 | return; 100 | } 101 | 102 | if is_data_available { 103 | app.load_route(index.unwrap()); 104 | } else { 105 | app.active_display_block = ActiveDisplayBlock::Loading; 106 | 107 | app.dispatch(IoEvent::GetAnimeRanking(app.anime_ranking_type.clone())); 108 | } 109 | } 110 | 111 | pub fn get_manga_ranking(app: &mut App) { 112 | let (is_data_available, is_next, index) = is_manga_ranking_data_available(app); 113 | 114 | let is_current_route = app 115 | .get_current_route() 116 | .is_some_and(|r| r.block == ActiveDisplayBlock::MangaRanking); 117 | if is_current_route { 118 | return; 119 | } 120 | app.reset_result_index(); 121 | 122 | if is_next { 123 | app.load_next_route(); 124 | return; 125 | } 126 | 127 | if is_data_available { 128 | app.load_route(index.unwrap()); 129 | } else { 130 | app.active_display_block = ActiveDisplayBlock::Loading; 131 | 132 | app.dispatch(IoEvent::GetMangaRanking(app.manga_ranking_type.clone())); 133 | } 134 | } 135 | 136 | fn is_anime_ranking_data_available(app: &App) -> (bool, bool, Option) { 137 | for i in 0..(app.navigator.history.len()) { 138 | let id = app.navigator.history[i]; 139 | if app.navigator.data[&id].block == ActiveDisplayBlock::AnimeRanking 140 | && app.navigator.data[&id].data.is_some() 141 | { 142 | if let Data::AnimeRanking(_) = app.navigator.data[&id].data.as_ref().unwrap() { 143 | let is_next = app.navigator.index + 1 == i; 144 | return (true, is_next, Some(id)); 145 | } 146 | } 147 | } 148 | (false, false, None) 149 | } 150 | 151 | fn is_manga_ranking_data_available(app: &App) -> (bool, bool, Option) { 152 | for i in 0..(app.navigator.history.len()) { 153 | let id = app.navigator.history[i]; 154 | if app.navigator.data[&id].block == ActiveDisplayBlock::MangaRanking 155 | && app.navigator.data[&id].data.is_some() 156 | { 157 | if let Data::MangaRanking(_) = app.navigator.data[&id].data.as_ref().unwrap() { 158 | let is_next = app.navigator.index + 1 == i; 159 | return (true, is_next, Some(id)); 160 | } 161 | } 162 | } 163 | (false, false, None) 164 | } 165 | 166 | fn get_suggestion(app: &mut App) { 167 | app.reset_result_index(); 168 | 169 | let (is_data_available, is_next, index) = is_suggestion_data_available(app); 170 | 171 | let is_current_route = app 172 | .get_current_route() 173 | .is_some_and(|r| r.block == ActiveDisplayBlock::Suggestions); 174 | 175 | if is_current_route { 176 | return; 177 | } 178 | 179 | app.start_card_list_index = 0; 180 | app.search_results.selected_display_card_index = Some(0); 181 | 182 | if is_next { 183 | app.load_next_route(); 184 | return; 185 | } 186 | 187 | if is_data_available { 188 | app.load_route(index.unwrap()); 189 | } else { 190 | app.active_display_block = ActiveDisplayBlock::Loading; 191 | 192 | app.dispatch(IoEvent::GetSuggestedAnime); 193 | } 194 | } 195 | 196 | fn is_suggestion_data_available(app: &App) -> (bool, bool, Option) { 197 | for i in 0..(app.navigator.history.len() - 1) { 198 | let id = app.navigator.history[i]; 199 | if app.navigator.data[&id].block == ActiveDisplayBlock::Suggestions 200 | && app.navigator.data[&id].data.is_some() 201 | { 202 | let is_next = app.navigator.index + 1 == i; 203 | return (true, is_next, Some(id)); 204 | } 205 | } 206 | (false, false, None) 207 | } 208 | -------------------------------------------------------------------------------- /src/handlers/common.rs: -------------------------------------------------------------------------------- 1 | // use crate::app::{ActiveBlock, App}; 2 | use crate::event::Key; 3 | 4 | pub fn down_event(key: Key) -> bool { 5 | matches!(key, Key::Down | Key::Char('j') | Key::Ctrl('n')) 6 | } 7 | 8 | pub fn up_event(key: Key) -> bool { 9 | matches!(key, Key::Up | Key::Char('k') | Key::Ctrl('p')) 10 | } 11 | 12 | pub fn left_event(key: Key) -> bool { 13 | matches!(key, Key::Left | Key::Char('h') | Key::Ctrl('b')) 14 | } 15 | 16 | pub fn right_event(key: Key) -> bool { 17 | matches!(key, Key::Right | Key::Char('l') | Key::Ctrl('f')) 18 | } 19 | 20 | pub fn on_down_press(selection_data: &[T], selection_index: Option) -> usize { 21 | match selection_index { 22 | Some(selection_index) => { 23 | if !selection_data.is_empty() { 24 | let next_index = selection_index + 1; 25 | if next_index > selection_data.len() - 1 { 26 | return 0; 27 | } else { 28 | return next_index; 29 | } 30 | } 31 | 0 32 | } 33 | None => 0, 34 | } 35 | } 36 | 37 | pub fn on_up_press(selection_data: &[T], selection_index: Option) -> usize { 38 | match selection_index { 39 | Some(selection_index) => { 40 | if !selection_data.is_empty() { 41 | if selection_index > 0 { 42 | return selection_index - 1; 43 | } else { 44 | return selection_data.len() - 1; 45 | } 46 | } 47 | 0 48 | } 49 | None => 0, 50 | } 51 | } 52 | 53 | pub fn quit_event(key: Key) -> bool { 54 | matches!(key, Key::Char('q') | Key::Ctrl('C') | Key::Ctrl('c')) 55 | } 56 | 57 | pub fn get_lowercase_key(key: Key) -> Key { 58 | match key { 59 | Key::Char(c) => Key::Char(c.to_ascii_lowercase()), 60 | Key::Ctrl(c) => Key::Ctrl(c.to_ascii_lowercase()), 61 | _ => key, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/handlers/display_block/manga_details.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{ActiveMangaDetailBlock, App, DetailPopup}, 3 | event::Key, 4 | handlers::common, 5 | }; 6 | 7 | use super::anime_details::{get_user_status_index, handle_edit}; 8 | 9 | pub fn handler(key: Key, app: &mut App) { 10 | match key { 11 | k if k == app.app_config.keys.toggle => change_tab(app), 12 | k if k == app.app_config.keys.open_popup => { 13 | if app.popup { 14 | handle_edit(app) 15 | } else { 16 | open_popup(app) 17 | } 18 | } 19 | k if common::down_event(k) => match app.active_manga_detail_block { 20 | ActiveMangaDetailBlock::SideInfo => { 21 | app.manga_details_info_scroll_view_state.scroll_down() 22 | } 23 | ActiveMangaDetailBlock::Synopsis => { 24 | app.manga_details_synopsys_scroll_view_state.scroll_down() 25 | } 26 | ActiveMangaDetailBlock::Chapters => { 27 | if app.popup && app.temp_popup_num != 0 { 28 | app.temp_popup_num -= 1; 29 | } 30 | } 31 | ActiveMangaDetailBlock::AddToList => { 32 | if app.popup { 33 | app.selected_popup_status = if app.selected_popup_status == 4 { 34 | 0 35 | } else { 36 | app.selected_popup_status + 1 37 | } 38 | } 39 | } 40 | ActiveMangaDetailBlock::Rate => { 41 | if app.popup { 42 | app.selected_popup_rate = if app.selected_popup_rate == 10 { 43 | 0 44 | } else { 45 | app.selected_popup_rate + 1 46 | } 47 | } 48 | } 49 | ActiveMangaDetailBlock::Volumes => { 50 | if app.popup && app.temp_popup_num != 0 { 51 | app.temp_popup_num -= 1; 52 | } 53 | } 54 | }, 55 | k if common::up_event(k) => match app.active_manga_detail_block { 56 | ActiveMangaDetailBlock::SideInfo => { 57 | app.manga_details_info_scroll_view_state.scroll_up(); 58 | } 59 | ActiveMangaDetailBlock::Synopsis => { 60 | app.manga_details_synopsys_scroll_view_state.scroll_up(); 61 | } 62 | ActiveMangaDetailBlock::Chapters => { 63 | if app.popup { 64 | let total_ch = app 65 | .manga_details 66 | .as_ref() 67 | .unwrap() 68 | .num_chapters 69 | .unwrap_or(10000); // just to let the user update the number even if the total is unkonw just like in mal. 70 | if total_ch == 0 || app.temp_popup_num as u64 != total_ch { 71 | app.temp_popup_num += 1; 72 | } 73 | } 74 | } 75 | ActiveMangaDetailBlock::AddToList => { 76 | if app.popup { 77 | app.selected_popup_status = if app.selected_popup_status == 0 { 78 | 4 79 | } else { 80 | app.selected_popup_status - 1 81 | } 82 | } 83 | } 84 | ActiveMangaDetailBlock::Rate => { 85 | if app.popup { 86 | app.selected_popup_rate = if app.selected_popup_rate == 0 { 87 | 10 88 | } else { 89 | app.selected_popup_rate - 1 90 | } 91 | } 92 | } 93 | ActiveMangaDetailBlock::Volumes => { 94 | if app.popup { 95 | let total_vol = app 96 | .manga_details 97 | .as_ref() 98 | .unwrap() 99 | .num_volumes 100 | .unwrap_or(10000); // just to let the user update the number even if the total is unkonw just like in mal. 101 | if total_vol == 0 || app.temp_popup_num as u64 != total_vol { 102 | app.temp_popup_num += 1; 103 | } 104 | } 105 | } 106 | }, 107 | k if common::right_event(k) => { 108 | if app.popup { 109 | return; 110 | } 111 | match app.active_manga_detail_block { 112 | ActiveMangaDetailBlock::AddToList => { 113 | app.active_manga_detail_block = ActiveMangaDetailBlock::Rate; 114 | } 115 | ActiveMangaDetailBlock::Rate => { 116 | app.active_manga_detail_block = ActiveMangaDetailBlock::Chapters; 117 | } 118 | ActiveMangaDetailBlock::Chapters => { 119 | app.active_manga_detail_block = ActiveMangaDetailBlock::Volumes; 120 | } 121 | ActiveMangaDetailBlock::Volumes => { 122 | app.active_manga_detail_block = ActiveMangaDetailBlock::AddToList; 123 | } 124 | _ => {} 125 | }; 126 | } 127 | k if common::left_event(k) => { 128 | if app.popup { 129 | return; 130 | } 131 | match app.active_manga_detail_block { 132 | ActiveMangaDetailBlock::AddToList => { 133 | app.active_manga_detail_block = ActiveMangaDetailBlock::Volumes; 134 | } 135 | ActiveMangaDetailBlock::Volumes => { 136 | app.active_manga_detail_block = ActiveMangaDetailBlock::Chapters; 137 | } 138 | ActiveMangaDetailBlock::Chapters => { 139 | app.active_manga_detail_block = ActiveMangaDetailBlock::Rate; 140 | } 141 | ActiveMangaDetailBlock::Rate => { 142 | app.active_manga_detail_block = ActiveMangaDetailBlock::AddToList; 143 | } 144 | _ => {} 145 | } 146 | } 147 | k if k == Key::Enter || k == app.app_config.keys.open_popup => { 148 | if app.popup { 149 | handle_edit(app) 150 | } else { 151 | open_popup(app) 152 | } 153 | } 154 | _ => {} 155 | } 156 | } 157 | 158 | fn change_tab(app: &mut App) { 159 | if app.popup { 160 | return; 161 | } 162 | match app.active_manga_detail_block { 163 | ActiveMangaDetailBlock::AddToList => { 164 | app.active_manga_detail_block = ActiveMangaDetailBlock::Rate; 165 | } 166 | ActiveMangaDetailBlock::Rate => { 167 | app.active_manga_detail_block = ActiveMangaDetailBlock::Chapters; 168 | } 169 | ActiveMangaDetailBlock::Chapters => { 170 | app.active_manga_detail_block = ActiveMangaDetailBlock::Volumes; 171 | } 172 | ActiveMangaDetailBlock::Volumes => { 173 | app.active_manga_detail_block = ActiveMangaDetailBlock::SideInfo; 174 | } 175 | ActiveMangaDetailBlock::SideInfo => { 176 | app.active_manga_detail_block = ActiveMangaDetailBlock::Synopsis; 177 | } 178 | ActiveMangaDetailBlock::Synopsis => { 179 | app.active_manga_detail_block = ActiveMangaDetailBlock::AddToList; 180 | } 181 | } 182 | } 183 | 184 | fn open_popup(app: &mut App) { 185 | match app.active_manga_detail_block { 186 | ActiveMangaDetailBlock::AddToList => { 187 | app.active_detail_popup = DetailPopup::AddToList; 188 | app.selected_popup_status = app 189 | .manga_details 190 | .as_ref() 191 | .unwrap() 192 | .my_list_status 193 | .as_ref() 194 | .map_or(0, |list| { 195 | get_user_status_index(list.status.to_string().as_str()) 196 | }); 197 | app.popup = true; 198 | } 199 | ActiveMangaDetailBlock::Rate => { 200 | app.active_detail_popup = DetailPopup::Rate; 201 | app.selected_popup_rate = app 202 | .manga_details 203 | .as_ref() 204 | .unwrap() 205 | .my_list_status 206 | .as_ref() 207 | .map_or(0, |list| list.score); 208 | app.popup = true; 209 | } 210 | ActiveMangaDetailBlock::Chapters => { 211 | app.active_detail_popup = DetailPopup::Chapters; 212 | app.temp_popup_num = app 213 | .manga_details 214 | .as_ref() 215 | .unwrap() 216 | .my_list_status 217 | .as_ref() 218 | .map_or(0, |list| list.num_chapters_read as u16); 219 | app.popup = true; 220 | } 221 | ActiveMangaDetailBlock::Volumes => { 222 | app.active_detail_popup = DetailPopup::Volumes; 223 | app.temp_popup_num = app 224 | .manga_details 225 | .as_ref() 226 | .unwrap() 227 | .my_list_status 228 | .as_ref() 229 | .map_or(0, |list| list.num_volumes_read as u16); 230 | app.popup = true; 231 | } 232 | _ => {} 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/handlers/display_block/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{ActiveDisplayBlock, App}, 3 | event::Key, 4 | }; 5 | mod anime_details; 6 | mod manga_details; 7 | mod ranking; 8 | mod result; 9 | mod seasonal; 10 | pub mod top_three; 11 | mod user_anime_list; 12 | mod user_manga_list; 13 | pub fn handle_display_block(key: Key, app: &mut App) { 14 | // todo: add handlers for each. 15 | match &app.active_display_block { 16 | ActiveDisplayBlock::SearchResultBlock => result::handler(key, app), 17 | ActiveDisplayBlock::Suggestions => result::handler(key, app), 18 | ActiveDisplayBlock::Help => {} 19 | ActiveDisplayBlock::UserInfo => {} 20 | ActiveDisplayBlock::UserAnimeList => user_anime_list::handler(key, app), 21 | ActiveDisplayBlock::UserMangaList => user_manga_list::handler(key, app), 22 | ActiveDisplayBlock::Seasonal => seasonal::handler(key, app), 23 | ActiveDisplayBlock::AnimeRanking => ranking::handler(key, app), 24 | ActiveDisplayBlock::MangaRanking => ranking::handler(key, app), 25 | ActiveDisplayBlock::AnimeDetails => anime_details::handler(key, app), 26 | ActiveDisplayBlock::MangaDetails => manga_details::handler(key, app), 27 | ActiveDisplayBlock::Loading => {} 28 | ActiveDisplayBlock::Error => {} 29 | ActiveDisplayBlock::Empty => { 30 | //? add toggle color for fun 31 | //? hard one: add playing the banner and moving it around 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/handlers/display_block/ranking.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | api::model::{AnimeRankingType, MangaRankingType}, 3 | app::{ActiveDisplayBlock, App}, 4 | event::Key, 5 | network::IoEvent, 6 | }; 7 | 8 | use super::result::handle_result_block; 9 | 10 | use crate::handlers::{ 11 | anime::{get_anime_ranking, get_manga_ranking}, 12 | common, 13 | }; 14 | 15 | pub fn handler(key: Key, app: &mut App) { 16 | if app.popup { 17 | handle_popup(key, app); 18 | } else { 19 | match key { 20 | k if k == app.app_config.keys.toggle => { 21 | if app.active_display_block == ActiveDisplayBlock::AnimeRanking { 22 | get_manga_ranking(app) 23 | } else { 24 | get_anime_ranking(app) 25 | } 26 | } 27 | 28 | k if k == app.app_config.keys.open_popup => { 29 | app.popup = true; 30 | } 31 | 32 | _ => handle_result_block(key, app), 33 | } 34 | } 35 | } 36 | 37 | fn handle_popup(key: Key, app: &mut App) { 38 | match key { 39 | k if common::up_event(k) => { 40 | if app.active_display_block == ActiveDisplayBlock::AnimeRanking { 41 | if app.anime_ranking_type_index > 0 { 42 | app.anime_ranking_type_index -= 1; 43 | } else { 44 | app.anime_ranking_type_index = 8; // max index 45 | } 46 | } else if app.manga_ranking_type_index > 0 { 47 | app.manga_ranking_type_index -= 1; 48 | } else { 49 | app.manga_ranking_type_index = 8; // max index 50 | } 51 | } 52 | 53 | k if common::down_event(k) => { 54 | if app.active_display_block == ActiveDisplayBlock::AnimeRanking { 55 | app.anime_ranking_type_index = (app.anime_ranking_type_index + 1) % 9; 56 | } else { 57 | app.manga_ranking_type_index = (app.manga_ranking_type_index + 1) % 9; 58 | } 59 | } 60 | Key::Enter => { 61 | if app.active_display_block == ActiveDisplayBlock::AnimeRanking { 62 | app.popup = false; 63 | app.active_display_block = ActiveDisplayBlock::Loading; 64 | app.anime_ranking_type = get_anime_rank(app.anime_ranking_type_index); 65 | app.dispatch(IoEvent::GetAnimeRanking(app.anime_ranking_type.clone())); 66 | } else { 67 | app.popup = false; 68 | app.active_display_block = ActiveDisplayBlock::Loading; 69 | app.manga_ranking_type = get_manga_rank(app.manga_ranking_type_index); 70 | app.dispatch(IoEvent::GetMangaRanking(app.manga_ranking_type.clone())); 71 | } 72 | } 73 | _ => {} 74 | } 75 | } 76 | 77 | fn get_anime_rank(i: u8) -> AnimeRankingType { 78 | match i { 79 | 0 => AnimeRankingType::All, 80 | 1 => AnimeRankingType::Airing, 81 | 2 => AnimeRankingType::Upcoming, 82 | 3 => AnimeRankingType::Movie, 83 | 4 => AnimeRankingType::ByPopularity, 84 | 5 => AnimeRankingType::Special, 85 | 6 => AnimeRankingType::TV, 86 | 7 => AnimeRankingType::OVA, 87 | 8 => AnimeRankingType::Favorite, 88 | _ => AnimeRankingType::Airing, 89 | } 90 | } 91 | 92 | fn get_manga_rank(i: u8) -> MangaRankingType { 93 | match i { 94 | 0 => MangaRankingType::All, 95 | 1 => MangaRankingType::Manga, 96 | 2 => MangaRankingType::Manhwa, 97 | 3 => MangaRankingType::ByPopularity, 98 | 4 => MangaRankingType::Novels, 99 | 5 => MangaRankingType::OneShots, 100 | 6 => MangaRankingType::Doujinshi, 101 | 7 => MangaRankingType::Manhua, 102 | 8 => MangaRankingType::Favorite, 103 | _ => MangaRankingType::All, 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/handlers/display_block/result.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{ActiveDisplayBlock, App, DISPLAY_COLUMN_NUMBER, DISPLAY_RAWS_NUMBER}; 2 | use crate::handlers::{common, get_media_detail_page}; 3 | use crate::ui::get_end_card_index; 4 | use crate::{app::SelectedSearchTab, event::Key}; 5 | pub fn handler(key: Key, app: &mut App) { 6 | match key { 7 | k if k == app.app_config.keys.toggle => match app.search_results.selected_tab { 8 | SelectedSearchTab::Anime => { 9 | app.reset_result_index(); 10 | app.search_results.selected_tab = SelectedSearchTab::Manga; 11 | } 12 | 13 | SelectedSearchTab::Manga => { 14 | app.reset_result_index(); 15 | app.search_results.selected_tab = SelectedSearchTab::Anime; 16 | } 17 | }, 18 | _ => handle_result_block(key, app), 19 | } 20 | } 21 | 22 | pub fn handle_result_block(key: Key, app: &mut App) { 23 | //? max is the last index of the current card list 24 | let max = get_end_card_index(app) - app.start_card_list_index as usize; 25 | match key { 26 | k if common::left_event(k) => { 27 | let mut index = app.search_results.selected_display_card_index.unwrap_or(0); 28 | let mut edges = Vec::new(); 29 | for i in (0..=max - 2).step_by(DISPLAY_COLUMN_NUMBER) { 30 | edges.push(i); 31 | } 32 | if !edges.contains(&index) { 33 | index = (index - 1) % (max + 1); 34 | } 35 | app.search_results.selected_display_card_index = Some(index); 36 | } 37 | 38 | k if common::right_event(k) => { 39 | let mut index = app.search_results.selected_display_card_index.unwrap_or(0); 40 | let mut edges = Vec::new(); 41 | for i in (DISPLAY_COLUMN_NUMBER - 1..=max).step_by(DISPLAY_COLUMN_NUMBER) { 42 | edges.push(i); 43 | } 44 | if !edges.contains(&index) { 45 | index = (index + 1) % (max + 1); 46 | } 47 | 48 | app.search_results.selected_display_card_index = Some(index); 49 | } 50 | 51 | k if common::up_event(k) => { 52 | let mut index = app.search_results.selected_display_card_index.unwrap_or(0); 53 | if (0..3).contains(&index) { 54 | scroll_results_up(app); 55 | } else if !(0..3).contains(&index) { 56 | index -= DISPLAY_COLUMN_NUMBER; 57 | app.search_results.selected_display_card_index = Some(index); 58 | } 59 | } 60 | 61 | k if common::down_event(k) => { 62 | let mut index = app.search_results.selected_display_card_index.unwrap_or(0); 63 | if ((DISPLAY_COLUMN_NUMBER * DISPLAY_RAWS_NUMBER - 3) 64 | ..(DISPLAY_COLUMN_NUMBER * DISPLAY_RAWS_NUMBER)) 65 | .contains(&index) 66 | { 67 | scroll_results_down(app); 68 | } else { 69 | index += DISPLAY_COLUMN_NUMBER; 70 | if index > max { 71 | index = max; // Ensure we don't go out of bounds 72 | } 73 | app.search_results.selected_display_card_index = Some(index); 74 | } 75 | } 76 | 77 | Key::Enter => get_media_detail_page(app), 78 | _ => {} 79 | } 80 | } 81 | 82 | fn scroll_results_up(app: &mut App) { 83 | app.start_card_list_index = app 84 | .start_card_list_index 85 | .saturating_sub(DISPLAY_COLUMN_NUMBER as u16); 86 | } 87 | 88 | fn scroll_results_down(app: &mut App) { 89 | let data_length = get_data_length(app) as usize; 90 | // Ensure that the end index does not exceed the data length 91 | // If it does, reset the the index to the start 92 | 93 | if get_end_card_index(app) + DISPLAY_COLUMN_NUMBER > data_length - 1 { 94 | app.start_card_list_index = 95 | (data_length - DISPLAY_COLUMN_NUMBER * DISPLAY_RAWS_NUMBER) as u16; 96 | } else if get_end_card_index(app) > data_length - 1 { 97 | let index_positoin = app 98 | .search_results 99 | .selected_display_card_index 100 | .as_ref() 101 | .unwrap() 102 | % DISPLAY_COLUMN_NUMBER; 103 | app.search_results.selected_display_card_index = Some(index_positoin); 104 | 105 | app.start_card_list_index = 0; 106 | } else { 107 | // If the end index is within bounds, increment the indixes 108 | app.start_card_list_index += DISPLAY_COLUMN_NUMBER as u16 109 | } 110 | } 111 | 112 | pub fn get_data_length(app: &App) -> u16 { 113 | let data_length = match app.active_display_block { 114 | ActiveDisplayBlock::SearchResultBlock => match app.search_results.selected_tab { 115 | SelectedSearchTab::Manga => app.search_results.manga.as_ref().unwrap().data.len(), 116 | SelectedSearchTab::Anime => app.search_results.anime.as_ref().unwrap().data.len(), 117 | }, 118 | ActiveDisplayBlock::MangaRanking => app.manga_ranking_data.as_ref().unwrap().data.len(), 119 | ActiveDisplayBlock::AnimeRanking => app.anime_ranking_data.as_ref().unwrap().data.len(), 120 | _ => app.search_results.anime.as_ref().unwrap().data.len(), 121 | }; 122 | data_length as u16 123 | } 124 | -------------------------------------------------------------------------------- /src/handlers/display_block/seasonal.rs: -------------------------------------------------------------------------------- 1 | use chrono::Datelike; 2 | 3 | use super::result::handle_result_block; 4 | use crate::{ 5 | api::model::Season, 6 | app::{ActiveDisplayBlock, App}, 7 | event::Key, 8 | network::IoEvent, 9 | }; 10 | 11 | use crate::handlers::common; 12 | 13 | pub fn handler(key: Key, app: &mut App) { 14 | if app.popup { 15 | handle_popup(key, app); 16 | } else { 17 | match key { 18 | // Key::Enter => open anime detail), 19 | k if k == app.app_config.keys.toggle => app.popup = true, 20 | 21 | // Key::Char('s') => app.active_display_block = ActiveDisplayBlock::, 22 | _ => handle_result_block(key, app), 23 | } 24 | } 25 | } 26 | 27 | fn reload_seasonal(app: &mut App) { 28 | app.reset_result_index(); 29 | app.active_display_block = ActiveDisplayBlock::Loading; 30 | app.popup = false; 31 | app.anime_season.anime_season.season = get_season(app); 32 | app.anime_season.anime_season.year = app.anime_season.selected_year as u64; 33 | app.dispatch(IoEvent::GetSeasonalAnime); 34 | } 35 | 36 | fn handle_popup(key: Key, app: &mut App) { 37 | let is_season_selected = app.anime_season.popup_season_highlight; 38 | match key { 39 | k if k == app.app_config.keys.toggle => { 40 | app.anime_season.popup_season_highlight = !is_season_selected; 41 | } 42 | 43 | k if common::down_event(k) => { 44 | if is_season_selected { 45 | app.anime_season.selected_season = (app.anime_season.selected_season + 1) % 4; 46 | } else if app.anime_season.selected_year > 1917 { 47 | // Ensure the selected year does not go below 1917, which is the last year available 48 | app.anime_season.selected_year -= 1; 49 | } else { 50 | app.anime_season.selected_year = 1917; 51 | } 52 | } 53 | 54 | k if common::up_event(k) => { 55 | if is_season_selected { 56 | if app.anime_season.selected_season == 0 { 57 | app.anime_season.selected_season = 3; 58 | } else { 59 | app.anime_season.selected_season = (app.anime_season.selected_season - 1) % 4; 60 | } 61 | } else if app.anime_season.selected_year < chrono::Utc::now().year_ce().1 as u16 { 62 | app.anime_season.selected_year += 1; 63 | } else { 64 | app.anime_season.selected_year = chrono::Utc::now().year_ce().1 as u16; 65 | } 66 | } 67 | 68 | Key::Enter => { 69 | app.popup = false; 70 | reload_seasonal(app); 71 | } 72 | 73 | _ => {} 74 | } 75 | } 76 | 77 | fn get_season(app: &App) -> Season { 78 | let season = app.anime_season.selected_season; 79 | match season { 80 | 0 => Season::Winter, 81 | 1 => Season::Spring, 82 | 2 => Season::Summer, 83 | 3 => Season::Fall, 84 | _ => panic!("Invalid season"), 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/handlers/display_block/top_three.rs: -------------------------------------------------------------------------------- 1 | use crate::handlers::{common, get_media_detail_page}; 2 | use crate::{ 3 | api::model::{AnimeRankingType, MangaRankingType, RankingType}, 4 | app::{App, TopThreeBlock}, 5 | event::Key, 6 | network::IoEvent, 7 | }; 8 | 9 | pub fn handler(key: Key, app: &mut App) { 10 | let mut index = app.selected_top_three; 11 | match key { 12 | k if common::up_event(k) => { 13 | if index > 0 { 14 | index -= 1; 15 | } else { 16 | index = 2; 17 | } 18 | } 19 | 20 | k if common::down_event(k) => { 21 | if index < 2 { 22 | index += 1; 23 | } else { 24 | index = 0; 25 | } 26 | } 27 | 28 | k if k == app.app_config.keys.toggle => match &app.active_top_three { 29 | TopThreeBlock::Anime(_) => { 30 | let data_available = is_manga_data_available( 31 | app, 32 | app.active_top_three_manga 33 | .as_ref() 34 | .unwrap_or(&app.available_manga_ranking_types[0]), 35 | ); 36 | 37 | if !data_available { 38 | app.active_top_three = TopThreeBlock::Loading(RankingType::MangaRankingType( 39 | app.active_top_three_manga 40 | .clone() 41 | .unwrap_or(app.available_manga_ranking_types[0].clone()), 42 | )); 43 | 44 | app.dispatch(IoEvent::GetTopThree(TopThreeBlock::Manga( 45 | app.active_top_three_manga 46 | .clone() 47 | .unwrap_or(app.available_manga_ranking_types[0].clone()), 48 | ))); 49 | } else { 50 | app.active_top_three = TopThreeBlock::Manga( 51 | app.active_top_three_manga 52 | .as_ref() 53 | .unwrap_or(&app.available_manga_ranking_types[0]) 54 | .clone(), 55 | ) 56 | } 57 | } 58 | 59 | TopThreeBlock::Manga(_) => { 60 | let data_available = is_anime_data_available( 61 | app, 62 | app.active_top_three_anime 63 | .as_ref() 64 | .unwrap_or(&app.available_anime_ranking_types[0]), 65 | ); 66 | 67 | if !data_available { 68 | app.active_top_three = TopThreeBlock::Loading(RankingType::AnimeRankingType( 69 | app.active_top_three_anime 70 | .clone() 71 | .unwrap_or(app.available_anime_ranking_types[0].clone()), 72 | )); 73 | 74 | app.dispatch(IoEvent::GetTopThree(TopThreeBlock::Anime( 75 | app.active_top_three_anime 76 | .clone() 77 | .unwrap_or(AnimeRankingType::Airing), 78 | ))); 79 | } else { 80 | app.active_top_three = TopThreeBlock::Anime( 81 | app.active_top_three_anime 82 | .as_ref() 83 | .unwrap_or(&app.available_anime_ranking_types[0]) 84 | .clone(), 85 | ); 86 | } 87 | } 88 | 89 | // reload the current block 90 | TopThreeBlock::Error(previous) => match previous { 91 | RankingType::AnimeRankingType(_) => { 92 | app.dispatch(IoEvent::GetTopThree(TopThreeBlock::Manga( 93 | app.active_top_three_manga 94 | .as_ref() 95 | .unwrap_or(&app.available_manga_ranking_types[0]) 96 | .clone(), 97 | ))) 98 | } 99 | RankingType::MangaRankingType(_) => { 100 | app.dispatch(IoEvent::GetTopThree(TopThreeBlock::Anime( 101 | app.active_top_three_anime 102 | .as_ref() 103 | .unwrap_or(&app.available_anime_ranking_types[0]) 104 | .clone(), 105 | ))) 106 | } 107 | }, 108 | _ => {} 109 | }, 110 | // switch between ranking types 111 | k if common::left_event(k) => match &app.active_top_three { 112 | TopThreeBlock::Anime(_) => { 113 | let mut index = app.active_anime_rank_index; 114 | let max = app.available_anime_ranking_types.len() as u32; 115 | decrement_index(&mut index, max); 116 | change_anime_top_three_block(app, index); 117 | } 118 | 119 | TopThreeBlock::Manga(_) => { 120 | let mut index = app.active_manga_rank_index; 121 | let max = app.available_manga_ranking_types.len() as u32; 122 | 123 | decrement_index(&mut index, max); 124 | change_manga_top_three_block(app, index); 125 | } 126 | TopThreeBlock::Error(previous) => match previous { 127 | RankingType::AnimeRankingType(_) => { 128 | let mut index = app.active_anime_rank_index; 129 | let max = app.available_anime_ranking_types.len() as u32; 130 | decrement_index(&mut index, max); 131 | change_anime_top_three_block(app, index) 132 | } 133 | 134 | RankingType::MangaRankingType(_) => { 135 | let mut index = app.active_manga_rank_index; 136 | let max = app.available_manga_ranking_types.len() as u32; 137 | decrement_index(&mut index, max); 138 | change_manga_top_three_block(app, index) 139 | } 140 | }, 141 | 142 | _ => {} 143 | }, 144 | k if common::right_event(k) => match &app.active_top_three { 145 | TopThreeBlock::Anime(_) => { 146 | let mut index = app.active_anime_rank_index; 147 | let max = app.available_anime_ranking_types.len() as u32; 148 | increment_index(&mut index, max); 149 | change_anime_top_three_block(app, index); 150 | } 151 | TopThreeBlock::Manga(_) => { 152 | let mut index = app.active_manga_rank_index; 153 | let max = app.available_manga_ranking_types.len() as u32; 154 | 155 | increment_index(&mut index, max); 156 | change_manga_top_three_block(app, index); 157 | } 158 | TopThreeBlock::Error(previous) => match previous { 159 | RankingType::AnimeRankingType(_) => { 160 | let mut index = app.active_anime_rank_index; 161 | let max = app.available_anime_ranking_types.len() as u32; 162 | increment_index(&mut index, max); 163 | change_anime_top_three_block(app, index) 164 | } 165 | 166 | RankingType::MangaRankingType(_) => { 167 | let mut index = app.active_manga_rank_index; 168 | let max = app.available_manga_ranking_types.len() as u32; 169 | increment_index(&mut index, max); 170 | change_manga_top_three_block(app, index) 171 | } 172 | }, 173 | _ => {} 174 | }, 175 | 176 | Key::Enter => get_media_detail_page(app), 177 | _ => {} 178 | } 179 | app.selected_top_three = index; 180 | } 181 | 182 | fn is_manga_data_available(app: &App, manga_type: &MangaRankingType) -> bool { 183 | match manga_type { 184 | MangaRankingType::All => app.top_three_manga.all.is_some(), 185 | MangaRankingType::Manga => app.top_three_manga.manga.is_some(), 186 | MangaRankingType::Novels => app.top_three_manga.novels.is_some(), 187 | MangaRankingType::OneShots => app.top_three_manga.oneshots.is_some(), 188 | MangaRankingType::Doujinshi => app.top_three_manga.doujin.is_some(), 189 | MangaRankingType::Manhwa => app.top_three_manga.manhwa.is_some(), 190 | MangaRankingType::Manhua => app.top_three_manga.manhua.is_some(), 191 | MangaRankingType::ByPopularity => app.top_three_manga.popular.is_some(), 192 | MangaRankingType::Favorite => app.top_three_manga.favourite.is_some(), 193 | MangaRankingType::Other(_) => false, 194 | } 195 | } 196 | 197 | fn is_anime_data_available(app: &App, anime_type: &AnimeRankingType) -> bool { 198 | match anime_type { 199 | AnimeRankingType::All => app.top_three_anime.all.is_some(), 200 | AnimeRankingType::Airing => app.top_three_anime.airing.is_some(), 201 | AnimeRankingType::Upcoming => app.top_three_anime.upcoming.is_some(), 202 | AnimeRankingType::TV => app.top_three_anime.tv.is_some(), 203 | AnimeRankingType::OVA => app.top_three_anime.ova.is_some(), 204 | AnimeRankingType::Movie => app.top_three_anime.movie.is_some(), 205 | AnimeRankingType::Special => app.top_three_anime.special.is_some(), 206 | AnimeRankingType::ByPopularity => app.top_three_anime.popular.is_some(), 207 | AnimeRankingType::Favorite => app.top_three_anime.favourite.is_some(), 208 | AnimeRankingType::Other(_) => false, 209 | } 210 | } 211 | 212 | fn change_anime_top_three_block(app: &mut App, index: u32) { 213 | let data_available = 214 | is_anime_data_available(app, &app.available_anime_ranking_types[index as usize]); 215 | 216 | if !data_available { 217 | app.active_top_three = TopThreeBlock::Loading(RankingType::AnimeRankingType( 218 | app.available_anime_ranking_types[index as usize].clone(), 219 | )); 220 | 221 | app.active_top_three_anime = 222 | Some(app.available_anime_ranking_types[index as usize].clone()); 223 | 224 | app.dispatch(IoEvent::GetTopThree(TopThreeBlock::Anime( 225 | app.available_anime_ranking_types[index as usize].clone(), 226 | ))); 227 | app.active_anime_rank_index = index; 228 | } else { 229 | app.active_top_three_anime = 230 | Some(app.available_anime_ranking_types[index as usize].clone()); 231 | 232 | app.active_top_three = 233 | TopThreeBlock::Anime(app.available_anime_ranking_types[index as usize].clone()); 234 | app.active_anime_rank_index = index; 235 | } 236 | } 237 | 238 | fn change_manga_top_three_block(app: &mut App, index: u32) { 239 | let data_available = 240 | is_manga_data_available(app, &app.available_manga_ranking_types[index as usize]); 241 | 242 | if !data_available { 243 | app.active_top_three = TopThreeBlock::Loading(RankingType::MangaRankingType( 244 | app.available_manga_ranking_types[index as usize].clone(), 245 | )); 246 | 247 | app.active_top_three_manga = 248 | Some(app.available_manga_ranking_types[index as usize].clone()); 249 | 250 | app.dispatch(IoEvent::GetTopThree(TopThreeBlock::Manga( 251 | app.available_manga_ranking_types[index as usize].clone(), 252 | ))); 253 | app.active_manga_rank_index = index; 254 | } else { 255 | app.active_top_three_manga = 256 | Some(app.available_manga_ranking_types[index as usize].clone()); 257 | 258 | app.active_top_three = 259 | TopThreeBlock::Manga(app.available_manga_ranking_types[index as usize].clone()); 260 | app.active_manga_rank_index = index; 261 | } 262 | } 263 | 264 | fn increment_index(index: &mut u32, max: u32) { 265 | if *index < max - 1 { 266 | *index += 1; 267 | } else { 268 | *index = 0; 269 | } 270 | } 271 | fn decrement_index(index: &mut u32, max: u32) { 272 | if *index > 0 { 273 | *index -= 1; 274 | } else { 275 | *index = max - 1; 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/handlers/display_block/user_anime_list.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{ActiveDisplayBlock, App}, 3 | event::Key, 4 | handlers::user::is_user_anime_list_data_available, 5 | network::IoEvent, 6 | }; 7 | 8 | use super::result; 9 | 10 | pub fn handler(key: Key, app: &mut App) { 11 | match key { 12 | k if k == app.app_config.keys.toggle => change_tab(app), 13 | k if k == app.app_config.keys.open_popup => {} // i don't remember what is the popup for 14 | _ => result::handler(key, app), 15 | } 16 | } 17 | 18 | fn change_tab(app: &mut App) { 19 | // we need to checkif the next route is the same as the the next status route then we call load_next_route() else we call load_route() 20 | // this way we won't overide the next route if it's the same as the next status route 21 | let next_status = app.next_anime_list_status(); 22 | app.anime_list_status = next_status.clone(); 23 | 24 | let (is_data_available, is_next, index) = is_user_anime_list_data_available(app); 25 | app.reset_result_index(); 26 | if is_next { 27 | app.load_next_route(); 28 | return; 29 | } 30 | if is_data_available { 31 | app.load_route(index.unwrap()); 32 | } else { 33 | app.active_display_block = ActiveDisplayBlock::Loading; 34 | app.dispatch(IoEvent::GetAnimeList(next_status)); 35 | } 36 | } 37 | 38 | // fn open_popup(app: &mut App) {} 39 | -------------------------------------------------------------------------------- /src/handlers/display_block/user_manga_list.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{ActiveDisplayBlock, App}, 3 | event::Key, 4 | handlers::user::is_user_manga_list_data_available, 5 | network::IoEvent, 6 | }; 7 | 8 | use super::result; 9 | 10 | pub fn handler(key: Key, app: &mut App) { 11 | match key { 12 | k if k == app.app_config.keys.toggle => change_tab(app), 13 | k if k == app.app_config.keys.open_popup => {} // i don't remember what is the popup for 14 | _ => result::handler(key, app), 15 | } 16 | } 17 | 18 | // fn open_popup(app: &mut App) {} 19 | 20 | fn change_tab(app: &mut App) { 21 | let next_status = app.next_anime_list_status(); 22 | app.anime_list_status = next_status.clone(); 23 | let (is_data_available, is_next, index) = is_user_manga_list_data_available(app); 24 | 25 | app.reset_result_index(); 26 | 27 | if is_next { 28 | app.load_next_route(); 29 | return; 30 | } 31 | if is_data_available { 32 | app.load_route(index.unwrap()); 33 | } else { 34 | app.active_display_block = ActiveDisplayBlock::Loading; 35 | app.dispatch(IoEvent::GetAnimeList(next_status)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/handlers/help.rs: -------------------------------------------------------------------------------- 1 | // use super::common; 2 | // use crate::app::App; 3 | // use crate::event::Key; 4 | 5 | // #[derive(PartialEq)] 6 | // enum Direction { 7 | // UP, 8 | // DOWN, 9 | // } 10 | 11 | // pub fn handler(key: Key, app: &mut App) { 12 | // match key { 13 | // k if common::down_event(k) => { 14 | // move_page(Direction::DOWN, app); 15 | // } 16 | // k if common::up_event(k) => { 17 | // move_page(Direction::UP, app); 18 | // } 19 | // Key::Ctrl('d') => { 20 | // move_page(Direction::DOWN, app); 21 | // } 22 | // Key::Ctrl('u') => { 23 | // move_page(Direction::UP, app); 24 | // } 25 | // _ => {} 26 | // }; 27 | // } 28 | 29 | // fn move_page(direction: Direction, app: &mut App) { 30 | // if direction == Direction::UP { 31 | // if app.help_menu_page > 0 { 32 | // app.help_menu_page -= 1; 33 | // } 34 | // } else if direction == Direction::DOWN { 35 | // app.help_menu_page += 1; 36 | // } 37 | // app.calculate_help_menu_offset(); 38 | // } 39 | -------------------------------------------------------------------------------- /src/handlers/input.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{ActiveBlock, ActiveDisplayBlock, App}; 2 | use crate::event::Key; 3 | use crate::network::IoEvent; 4 | use std::convert::TryInto; 5 | use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; 6 | 7 | pub fn handler(key: Key, app: &mut App) { 8 | match key { 9 | // Delete everything after the cursor including selected character 10 | Key::Ctrl('k') => { 11 | app.input.drain(app.input_idx..app.input.len()); 12 | } 13 | 14 | // Delete everything before the cursor not including selected character 15 | Key::Ctrl('u') => { 16 | app.input.drain(..app.input_idx); 17 | app.input_idx = 0; 18 | app.input_cursor_position = 0; 19 | } 20 | 21 | // Deletes everything in input 22 | Key::Ctrl('l') => { 23 | app.input = vec![]; 24 | app.input_idx = 0; 25 | app.input_cursor_position = 0; 26 | } 27 | // Delete word before cursor 28 | Key::Ctrl('w') => { 29 | if app.input_cursor_position == 0 { 30 | return; 31 | } 32 | let word_end = match app.input[..app.input_idx].iter().rposition(|&x| x != ' ') { 33 | Some(index) => index + 1, 34 | None => 0, 35 | }; 36 | let word_start = match app.input[..word_end].iter().rposition(|&x| x == ' ') { 37 | Some(index) => index + 1, 38 | None => 0, 39 | }; 40 | let deleted: String = app.input[word_start..app.input_idx].iter().collect(); 41 | let deleted_len: u16 = UnicodeWidthStr::width(deleted.as_str()).try_into().unwrap(); 42 | app.input.drain(word_start..app.input_idx); 43 | app.input_idx = word_start; 44 | app.input_cursor_position -= deleted_len; 45 | } 46 | 47 | // Move cursor to the end of the input 48 | Key::Ctrl('e') => { 49 | app.input_idx = app.input.len(); 50 | let input_string: String = app.input.iter().collect(); 51 | app.input_cursor_position = UnicodeWidthStr::width(input_string.as_str()) 52 | .try_into() 53 | .unwrap(); 54 | } 55 | 56 | // Move cursor to the start of the input 57 | Key::Ctrl('a') => { 58 | app.input_idx = 0; 59 | app.input_cursor_position = 0; 60 | } 61 | 62 | // Move cursor to left 63 | Key::Left | Key::Ctrl('b') => { 64 | if !app.input.is_empty() && app.input_idx > 0 { 65 | let last_c = app.input[app.input_idx - 1]; 66 | app.input_idx -= 1; 67 | app.input_cursor_position -= compute_character_width(last_c); 68 | } 69 | } 70 | 71 | // Move cursor to right 72 | Key::Right | Key::Ctrl('f') => { 73 | if app.input_idx < app.input.len() { 74 | let next_c = app.input[app.input_idx]; 75 | app.input_idx += 1; 76 | app.input_cursor_position += compute_character_width(next_c); 77 | } 78 | } 79 | 80 | // end input mode 81 | Key::Esc => { 82 | app.active_block = ActiveBlock::DisplayBlock; 83 | } 84 | 85 | // Submit search query 86 | Key::Enter => { 87 | let input_str: String = app.input.iter().collect(); 88 | 89 | // Don't do anything if there is no input 90 | if input_str.is_empty() { 91 | return; 92 | } 93 | app.active_display_block = ActiveDisplayBlock::Loading; 94 | app.active_block = ActiveBlock::DisplayBlock; 95 | app.reset_result_index(); 96 | app.display_block_title = format!("Search Results: {}", input_str).to_string(); 97 | 98 | app.dispatch(IoEvent::GetSearchResults(input_str.clone())); 99 | 100 | // On searching for a track, clear the playlist selection 101 | } 102 | 103 | // add character to input 104 | Key::Char(c) => { 105 | app.input.insert(app.input_idx, c); 106 | app.input_idx += 1; 107 | app.input_cursor_position += compute_character_width(c); 108 | } 109 | 110 | // delete character before cursor 111 | Key::Backspace | Key::Ctrl('h') => { 112 | if !app.input.is_empty() && app.input_idx > 0 { 113 | let last_c = app.input.remove(app.input_idx - 1); 114 | app.input_idx -= 1; 115 | app.input_cursor_position -= compute_character_width(last_c); 116 | } 117 | } 118 | 119 | // ! not working ?? 120 | Key::Delete | Key::Ctrl('d') => { 121 | if !app.input.is_empty() && app.input_idx < app.input.len() { 122 | app.input.remove(app.input_idx); 123 | } 124 | } 125 | 126 | _ => {} 127 | } 128 | } 129 | 130 | fn compute_character_width(character: char) -> u16 { 131 | UnicodeWidthChar::width(character) 132 | .unwrap() 133 | .try_into() 134 | .unwrap() 135 | } 136 | -------------------------------------------------------------------------------- /src/handlers/option.rs: -------------------------------------------------------------------------------- 1 | use super::common; 2 | use crate::app::{App, GENERAL_OPTIONS, GENERAL_OPTIONS_RANGE}; 3 | use crate::event::Key; 4 | 5 | pub fn handler(key: Key, app: &mut App) { 6 | match key { 7 | k if common::down_event(k) => { 8 | // calculate the next index in the list 9 | let next_index = GENERAL_OPTIONS_RANGE.start 10 | + common::on_down_press( 11 | &GENERAL_OPTIONS, 12 | Some(app.library.selected_index % GENERAL_OPTIONS_RANGE.len()), 13 | ); 14 | app.library.selected_index = next_index; 15 | } 16 | 17 | k if common::up_event(k) => { 18 | // calculate the next index in the list 19 | let next_index = GENERAL_OPTIONS_RANGE.start 20 | + common::on_up_press( 21 | &GENERAL_OPTIONS, 22 | Some(app.library.selected_index % GENERAL_OPTIONS_RANGE.len()), 23 | ); 24 | 25 | app.library.selected_index = next_index; 26 | } 27 | 28 | Key::Enter => { 29 | match app.library.selected_index { 30 | // Help 31 | 6 => {} 32 | // About 33 | 7 => {} 34 | // Quit 35 | 8 => {} 36 | 37 | _ => {} 38 | }; 39 | app.library.selected_index = 9; 40 | } 41 | _ => (), 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/handlers/user.rs: -------------------------------------------------------------------------------- 1 | use super::common; 2 | use crate::app::{ActiveDisplayBlock, App, Data, USER_OPTIONS, USER_OPTIONS_RANGE}; 3 | 4 | use crate::event::Key; 5 | use crate::network::IoEvent; 6 | 7 | pub fn handler(key: Key, app: &mut App) { 8 | match key { 9 | k if common::down_event(k) => { 10 | let next_index = USER_OPTIONS_RANGE.start 11 | + common::on_down_press( 12 | &USER_OPTIONS, 13 | Some(app.library.selected_index % USER_OPTIONS_RANGE.len()), 14 | ); 15 | app.library.selected_index = next_index; 16 | } 17 | k if common::up_event(k) => { 18 | let next_index = USER_OPTIONS_RANGE.start 19 | + common::on_up_press( 20 | &USER_OPTIONS, 21 | Some(app.library.selected_index % USER_OPTIONS_RANGE.len()), 22 | ); 23 | app.library.selected_index = next_index; 24 | } 25 | 26 | Key::Enter => { 27 | match app.library.selected_index { 28 | // profile 29 | 3 => get_user_profile(app), 30 | // animeList 31 | 4 => get_user_anime_list(app), 32 | // mangaList 33 | 5 => get_user_manga_list(app), 34 | // This is required because Rust can't tell if this pattern in exhaustive 35 | _ => {} 36 | }; 37 | app.library.selected_index = 9; 38 | } 39 | _ => (), 40 | }; 41 | } 42 | 43 | fn get_user_anime_list(app: &mut App) { 44 | let (is_data_available, is_next, index) = is_user_anime_list_data_available(app); 45 | if is_next { 46 | app.load_next_route(); 47 | return; 48 | } 49 | if is_data_available { 50 | app.load_route(index.unwrap()); 51 | } else { 52 | app.active_display_block = ActiveDisplayBlock::Loading; 53 | app.dispatch(IoEvent::GetAnimeList(app.anime_list_status.clone())); 54 | } 55 | } 56 | 57 | fn get_user_manga_list(app: &mut App) { 58 | let (is_data_available, is_next, index) = is_user_manga_list_data_available(app); 59 | if is_next { 60 | app.load_next_route(); 61 | return; 62 | } 63 | if is_data_available { 64 | app.load_route(index.unwrap()); 65 | } else { 66 | app.active_display_block = ActiveDisplayBlock::Loading; 67 | app.dispatch(IoEvent::GetMangaList(app.manga_list_status.clone())); 68 | } 69 | } 70 | 71 | fn get_user_profile(app: &mut App) { 72 | let (is_data_available, is_next, index) = is_user_profile_data_available(app); 73 | if is_next { 74 | app.load_next_route(); 75 | return; 76 | } 77 | if is_data_available { 78 | app.load_route(index.unwrap()); 79 | } else { 80 | app.active_display_block = ActiveDisplayBlock::Loading; 81 | app.dispatch(IoEvent::GetUserInfo); 82 | } 83 | } 84 | 85 | pub fn is_user_anime_list_data_available(app: &App) -> (bool, bool, Option) { 86 | for i in 0..(app.navigator.history.len()) { 87 | let id: u16 = app.navigator.history[i]; 88 | if app.navigator.data[&id].block == ActiveDisplayBlock::UserAnimeList 89 | && app.navigator.data[&id].data.is_some() 90 | { 91 | if let Data::UserAnimeList(d) = app.navigator.data[&id].data.as_ref().unwrap() { 92 | if d.status == app.anime_list_status { 93 | let is_next = app.navigator.index + 1 == i; 94 | return (true, is_next, Some(id)); 95 | } 96 | } 97 | } 98 | } 99 | (false, false, None) 100 | } 101 | 102 | pub fn is_user_manga_list_data_available(app: &App) -> (bool, bool, Option) { 103 | for i in 0..(app.navigator.history.len()) { 104 | let id = app.navigator.history[i]; 105 | if app.navigator.data[&id].block == ActiveDisplayBlock::UserMangaList 106 | && app.navigator.data[&id].data.is_some() 107 | { 108 | if let Data::UserMangaList(d) = app.navigator.data[&id].data.as_ref().unwrap() { 109 | if d.status == app.manga_list_status { 110 | let is_next = app.navigator.index + 1 == i; 111 | return (true, is_next, Some(id)); 112 | } 113 | } 114 | } 115 | } 116 | (false, false, None) 117 | } 118 | 119 | fn is_user_profile_data_available(app: &App) -> (bool, bool, Option) { 120 | for i in 0..(app.navigator.history.len()) { 121 | let id = app.navigator.history[i]; 122 | if app.navigator.data[&id].block == ActiveDisplayBlock::UserInfo 123 | && app.navigator.data[&id].data.is_some() 124 | { 125 | let is_next = app.navigator.index + 1 == i; 126 | return (true, is_next, Some(id)); 127 | } 128 | } 129 | (false, false, None) 130 | } 131 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | /* 2 | App State 3 | - The actual app state 4 | 5 | Mutations 6 | - Actually mutates the app 7 | - Keeps a log for backtracking 8 | 9 | Actions --| 10 | actions to call from mutations | 11 | |--> Accessible 12 | | 13 | Getters --| 14 | get values from the app state 15 | 16 | Shared Application State 17 | Flow of User Input -> Handle Events 18 | Rendering UI 19 | Routing 20 | 21 | */ 22 | 23 | /// Authorization 24 | pub mod auth; 25 | 26 | /// API request functions 27 | pub mod api; 28 | 29 | /// UI 30 | pub mod ui; 31 | 32 | /// Config 33 | pub mod config; 34 | 35 | /// App 36 | pub mod app; 37 | 38 | /// Network 39 | pub mod network; 40 | 41 | /// Events 42 | pub mod event; 43 | 44 | /// Handlers 45 | pub mod handlers; 46 | 47 | /// Cli 48 | pub mod cli; 49 | 50 | pub mod logging; 51 | -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- 1 | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; 2 | 3 | pub fn initialize_logging() { 4 | tracing_subscriber::registry() 5 | .with(tui_logger::TuiTracingSubscriberLayer) 6 | .with(tracing_subscriber::filter::LevelFilter::DEBUG) 7 | .init(); 8 | tui_logger::init_logger(log::LevelFilter::Warn).unwrap(); 9 | tui_logger::set_default_level(log::LevelFilter::Trace); 10 | } 11 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Result; 2 | use crossterm::cursor; 3 | use crossterm::execute; 4 | use crossterm::terminal; 5 | use crossterm::{cursor::MoveTo, ExecutableCommand}; 6 | use mal::api::model::RankingType; 7 | use mal::handlers::common; 8 | use mal::logging::initialize_logging; 9 | use ratatui::prelude::CrosstermBackend; 10 | use ratatui::Terminal; 11 | 12 | use std::sync::Arc; 13 | use std::{ 14 | io::{self}, //Write 15 | panic, 16 | }; 17 | use tokio::sync::Mutex; 18 | 19 | use mal::app::*; 20 | use mal::auth::OAuth; 21 | // use mal::cli::{Opt, StructOpt}; 22 | use mal::config::{app_config::AppConfig, oauth_config::AuthConfig}; 23 | use mal::event; 24 | use mal::event::key::Key; 25 | use mal::handlers; 26 | use mal::network::{IoEvent, Network}; 27 | use mal::ui; 28 | 29 | fn setup_terminal() -> Result<()> { 30 | let mut stdout = io::stdout(); 31 | 32 | execute!(stdout, terminal::EnterAlternateScreen)?; 33 | execute!(stdout, cursor::Hide)?; 34 | 35 | execute!(stdout, terminal::Clear(terminal::ClearType::All))?; 36 | 37 | execute!(stdout, crossterm::event::EnableMouseCapture)?; 38 | 39 | terminal::enable_raw_mode()?; 40 | Ok(()) 41 | } 42 | 43 | fn cleanup_terminal() -> Result<()> { 44 | let mut stdout = io::stdout(); 45 | 46 | execute!(stdout, crossterm::event::DisableMouseCapture)?; 47 | 48 | execute!(stdout, cursor::MoveTo(0, 0))?; 49 | execute!(stdout, terminal::Clear(terminal::ClearType::All))?; 50 | 51 | execute!(stdout, terminal::LeaveAlternateScreen)?; 52 | execute!(stdout, cursor::Show)?; 53 | 54 | terminal::disable_raw_mode()?; 55 | Ok(()) 56 | } 57 | 58 | /// Makes sure that the terminal cleans up even when there's a panic 59 | fn setup_panic_hook() { 60 | panic::set_hook(Box::new(|panic_info| { 61 | cleanup_terminal().unwrap(); 62 | better_panic::Settings::auto().create_panic_handler()(panic_info); 63 | })); 64 | } 65 | 66 | #[tokio::main] 67 | async fn main() -> Result<()> { 68 | better_panic::install(); 69 | setup_panic_hook(); 70 | let exit = mal::cli::handle_args(); 71 | if exit { 72 | return Ok(()); 73 | } 74 | 75 | // initialize logging 76 | initialize_logging(); 77 | // Get config 78 | println!("==> Loading Configiration"); 79 | let app_config = AppConfig::load()?; 80 | println!("==> Auth Configuration Loading"); 81 | let auth_config = AuthConfig::load()?; 82 | println!("==> Refreshing Token"); 83 | let oauth = OAuth::get_auth_async(auth_config).await?; 84 | let (sync_io_tx, sync_io_rx) = std::sync::mpsc::channel::(); 85 | 86 | // initialize app state 87 | let app = Arc::new(Mutex::new(App::new(sync_io_tx, app_config.clone()))); 88 | 89 | let cloned_app = Arc::clone(&app); 90 | std::thread::spawn(move || { 91 | let mut network = Network::new(oauth, &app, app_config.search_limit); 92 | start_network(sync_io_rx, &mut network); 93 | }); 94 | 95 | // run ui 96 | start_ui(app_config, &cloned_app).await?; 97 | 98 | Ok(()) 99 | } 100 | 101 | #[tokio::main] 102 | async fn start_network(io_rx: std::sync::mpsc::Receiver, network: &mut Network) { 103 | while let Ok(io_event) = io_rx.recv() { 104 | network.handle_network_event(io_event).await; 105 | } 106 | } 107 | 108 | async fn start_ui(app_config: AppConfig, app: &Arc>) -> Result<()> { 109 | // set up terminal 110 | let backend = CrosstermBackend::new(io::stdout()); 111 | let mut terminal = Terminal::new(backend)?; 112 | setup_terminal()?; 113 | 114 | let events = event::Events::new(app_config.behavior.tick_rate_milliseconds); 115 | { 116 | // initialize top three block 117 | let mut app = app.lock().await; 118 | app.active_top_three = TopThreeBlock::Loading(RankingType::AnimeRankingType( 119 | app_config.top_three_anime_types[0].clone(), 120 | )); 121 | app.active_top_three_anime = Some(app_config.top_three_anime_types[0].clone()); 122 | 123 | app.active_top_three_manga = Some(app_config.top_three_manga_types[0].clone()); 124 | app.dispatch(IoEvent::GetTopThree(TopThreeBlock::Anime( 125 | app_config.top_three_anime_types[0].clone(), 126 | ))); 127 | } 128 | 129 | loop { 130 | let mut app = app.lock().await; 131 | if app.exit_flag { 132 | // if exit_flag is set, we exit the app 133 | break; 134 | } 135 | 136 | let current_block = app.active_block; 137 | terminal.draw(|f| ui::draw_main_layout(f, &mut app))?; 138 | 139 | if current_block == ActiveBlock::Input { 140 | terminal.show_cursor()?; 141 | } else { 142 | terminal.hide_cursor()?; 143 | } 144 | 145 | let cursor_offset = if app.size.height > ui::util::SMALL_TERMINAL_HEIGHT { 146 | 2 147 | } else { 148 | 1 149 | }; 150 | 151 | terminal.backend_mut().execute(MoveTo( 152 | cursor_offset + app.input_cursor_position, 153 | cursor_offset, 154 | ))?; 155 | 156 | /* 157 | there are five blocks: 158 | 1.Input 159 | 2.AnimeMenu 160 | 3.MangaMenu 161 | 4.UserMenu 162 | 5.DisplayBlock 163 | 164 | and there are different display blocks : 165 | 1.SearchResultBlock 166 | 2.Help 167 | 3.UserInfo 168 | 4.UserAnimeList, 169 | 5.UserMangaList 170 | 6.Suggestions 171 | 7.Seasonal 172 | 8.AnimeRanking 173 | 9.MangaRanking 174 | 10.Loading 175 | 11.Error 176 | 12.Empty 177 | 178 | we switch between blocks by pressing Tab and between display by input and navigation 179 | we will implement a stack for display block to allow going back and forth 180 | */ 181 | if let event::Event::Input(key) = events.next()? { 182 | let key = common::get_lowercase_key(key); 183 | 184 | let active_block = app.active_block; 185 | // change the default of menu selecting to None when leaving the block 186 | if key == Key::Tab { 187 | // handle navigation between block 188 | handlers::handle_tab(&mut app); 189 | } else if key == Key::BackTab { 190 | // handle navigation between block 191 | handlers::handle_back_tab(&mut app); 192 | } else if active_block == ActiveBlock::Input { 193 | handlers::input_handler(key, &mut app); 194 | } else if common::quit_event(key) { 195 | app.exit_confirmation_popup = true; 196 | } else if key == app.app_config.keys.back { 197 | if app.active_block != ActiveBlock::Input { 198 | app.load_previous_route(); 199 | break; 200 | } 201 | } else { 202 | handlers::handle_app(key, &mut app); 203 | } 204 | } 205 | } 206 | 207 | // clean up terminal 208 | cleanup_terminal()?; 209 | Ok(()) 210 | } 211 | -------------------------------------------------------------------------------- /src/ui/display_block/empty.rs: -------------------------------------------------------------------------------- 1 | use crate::app::App; 2 | use figlet_rs::FIGfont; 3 | use ratatui::layout::Flex; 4 | use ratatui::style::{Modifier, Style}; 5 | use ratatui::text::Line; 6 | use ratatui::widgets::Block; 7 | use ratatui::{ 8 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 9 | widgets::Paragraph, 10 | Frame, 11 | }; 12 | 13 | pub fn draw_empty(f: &mut Frame, app: &App, chunk: Rect) { 14 | let [banner_layout] = Layout::default() 15 | .direction(Direction::Vertical) 16 | .constraints([Constraint::Length(6)]) 17 | .flex(Flex::Center) 18 | .areas(chunk); 19 | draw_figlet(f, "MAL-CLI".to_string(), banner_layout, app); 20 | } 21 | 22 | pub fn draw_figlet(f: &mut Frame, string: String, chunk: Rect, app: &App) { 23 | let standard_font = FIGfont::standard().unwrap(); 24 | let figlet = standard_font.convert(&string); 25 | let fig_string = figlet.unwrap().to_string(); 26 | 27 | let banner_lines: Vec<&str> = fig_string.lines().collect(); 28 | 29 | let style = Style::new() 30 | .fg(app.app_config.theme.banner) 31 | .add_modifier(Modifier::BOLD); 32 | 33 | let spans: Vec = banner_lines 34 | .iter() 35 | .map(|line| Line::styled(*line, style)) 36 | .collect(); 37 | let block = Block::default(); 38 | let height = spans.len() as u16; 39 | let paragraph = Paragraph::new(spans) 40 | .block(block) 41 | .alignment(Alignment::Center); 42 | 43 | let [centered_chunk] = Layout::default() 44 | .direction(Direction::Vertical) 45 | .constraints([Constraint::Length(height)]) 46 | .flex(Flex::Center) 47 | .areas(chunk); 48 | f.render_widget(paragraph, centered_chunk); 49 | } 50 | -------------------------------------------------------------------------------- /src/ui/display_block/error.rs: -------------------------------------------------------------------------------- 1 | use super::center_area; 2 | use ratatui::{ 3 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 4 | style::Style, 5 | text::{Line, Span}, 6 | widgets::{Block, BorderType, Borders, Paragraph, Wrap}, 7 | Frame, 8 | }; 9 | 10 | use crate::app::App; 11 | 12 | pub fn draw_error(f: &mut Frame, app: &App, chunk: Rect) { 13 | let error_raw = Layout::default() 14 | .direction(Direction::Vertical) 15 | .constraints([ 16 | Constraint::Percentage(40), 17 | Constraint::Percentage(20), 18 | Constraint::Percentage(40), 19 | ]) 20 | .split(chunk)[1]; 21 | 22 | let error_box = Layout::default() 23 | .direction(Direction::Horizontal) 24 | .constraints([ 25 | Constraint::Percentage(35), 26 | Constraint::Percentage(30), 27 | Constraint::Percentage(35), 28 | ]) 29 | .split(error_raw)[1]; 30 | 31 | let error_block = Block::default() 32 | .borders(Borders::ALL) 33 | .border_style(Style::default().fg(app.app_config.theme.error_border)) 34 | .border_type(BorderType::Double) 35 | .title(Span::styled( 36 | "ERROR", 37 | Style::default().fg(app.app_config.theme.error_text), 38 | )) 39 | .title_alignment(Alignment::Center); 40 | 41 | f.render_widget(error_block.clone(), error_box); 42 | 43 | let error_text = Line::from(app.api_error.clone()) 44 | .centered() 45 | .alignment(Alignment::Center); 46 | 47 | let error_paragraph = Paragraph::new(error_text) 48 | // .style(Style::default().fg(app.app_config.theme.text)) 49 | // .block(error_block) 50 | .wrap(Wrap { trim: true }); 51 | 52 | let centered_box = center_area(error_box, 100, 50); 53 | f.render_widget(error_paragraph, centered_box); 54 | } 55 | -------------------------------------------------------------------------------- /src/ui/display_block/loading.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Alignment, Constraint, Direction, Flex, Layout, Rect}, 3 | style::{Modifier, Style}, 4 | text::Line, 5 | widgets::Paragraph, 6 | Frame, 7 | }; 8 | 9 | use crate::app::App; 10 | 11 | pub fn draw_centered_line(f: &mut Frame, app: &App, chunk: Rect, line: &str) { 12 | let [loading_layout] = Layout::default() 13 | .direction(Direction::Vertical) 14 | .constraints([Constraint::Length(2)]) 15 | .flex(Flex::Center) 16 | .areas(chunk); 17 | 18 | // let loading_str = "Loading..."; 19 | 20 | let style = Style::new() 21 | .fg(app.app_config.theme.banner) 22 | .add_modifier(Modifier::BOLD); 23 | 24 | let loading_line = Line::styled(line, style); 25 | 26 | let paragraph = Paragraph::new(loading_line) 27 | // .block(paragraph_block) 28 | .alignment(Alignment::Center) 29 | .wrap(ratatui::widgets::Wrap { trim: true }); 30 | 31 | f.render_widget(paragraph, loading_layout); 32 | } 33 | -------------------------------------------------------------------------------- /src/ui/display_block/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::api::model::{UserReadStatus, UserWatchStatus}; 2 | use crate::app::{ActiveBlock, ActiveDisplayBlock, App}; 3 | use ratatui::layout::{Alignment, Constraint, Direction, Flex, Layout}; 4 | use ratatui::style::{Color, Style}; 5 | use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; 6 | use ratatui::{layout::Rect, Frame}; 7 | mod error; 8 | mod seasonal; 9 | use super::util::get_color; 10 | mod anime_details; 11 | mod details_utils; 12 | mod empty; 13 | mod loading; 14 | mod manga_details; 15 | mod ranking; 16 | mod results; 17 | mod search; 18 | mod suggestion; 19 | mod user; 20 | mod user_anime_list; 21 | mod user_manga_list; 22 | 23 | pub fn draw_display_layout(f: &mut Frame, app: &mut App, chunk: Rect) { 24 | let current_display_block = &app.active_display_block; 25 | 26 | draw_main_display_layout(f, app, chunk); 27 | 28 | match current_display_block { 29 | ActiveDisplayBlock::Empty => empty::draw_empty(f, app, chunk), 30 | 31 | ActiveDisplayBlock::Help => {} // draw_help_menu(f, app); 32 | 33 | ActiveDisplayBlock::AnimeDetails => anime_details::draw_anime_detail(f, app, chunk), 34 | 35 | ActiveDisplayBlock::MangaDetails => manga_details::draw_manga_detail(f, app, chunk), 36 | 37 | ActiveDisplayBlock::AnimeRanking => ranking::draw_anime_ranking(f, app, chunk), 38 | 39 | ActiveDisplayBlock::MangaRanking => ranking::draw_manga_ranking(f, app, chunk), 40 | 41 | ActiveDisplayBlock::Suggestions => suggestion::draw_suggestions(f, app, chunk), 42 | 43 | ActiveDisplayBlock::UserAnimeList => user_anime_list::draw_user_anime_list(f, app, chunk), 44 | 45 | ActiveDisplayBlock::UserMangaList => user_manga_list::draw_user_manga_list(f, app, chunk), 46 | 47 | ActiveDisplayBlock::UserInfo => user::draw_user_info(f, app, chunk), 48 | 49 | ActiveDisplayBlock::SearchResultBlock => search::draw_search_result(f, app, chunk), 50 | 51 | ActiveDisplayBlock::Seasonal => seasonal::draw_seasonal_anime(f, app, chunk), 52 | 53 | ActiveDisplayBlock::Error => error::draw_error(f, app, chunk), 54 | 55 | ActiveDisplayBlock::Loading => { 56 | if app.is_loading { 57 | loading::draw_centered_line(f, app, chunk, "Loading..."); 58 | } 59 | } 60 | } 61 | if app.exit_confirmation_popup { 62 | draw_exit_confirmation_popup(f, app, chunk) 63 | } 64 | } 65 | 66 | pub fn draw_main_display_layout(f: &mut Frame, app: &App, chunk: Rect) { 67 | let highlight_state = app.active_block == ActiveBlock::DisplayBlock; 68 | 69 | let block = Block::default() 70 | .borders(Borders::ALL) 71 | .border_type(BorderType::Rounded) 72 | .border_style(get_color(highlight_state, app.app_config.theme)); 73 | 74 | f.render_widget(block, chunk); 75 | } 76 | 77 | pub const NAVIGATION_KEYS: [(&str, &str); 5] = [ 78 | ("s", "Switch Type"), 79 | ("q", "Quit"), 80 | ("arrows", "Navigate"), 81 | ("n", "Next page"), 82 | ("p", "Previous page"), 83 | ]; 84 | pub const DETAILS_NAVIGATION_KEYS: [(&str, &str); 3] = 85 | [("s/arrows", "Navigate"), ("q", "Quit"), ("enter", "Select")]; 86 | 87 | pub fn draw_keys_bar(f: &mut Frame, app: &App, chunk: Rect) -> Rect { 88 | let [display_chunk, keys_chunk] = Layout::default() 89 | .direction(Direction::Vertical) 90 | .margin(0) 91 | .constraints([Constraint::Percentage(95), Constraint::Length(2)]) 92 | .areas(chunk); 93 | 94 | let keys = match app.active_display_block { 95 | ActiveDisplayBlock::AnimeDetails | ActiveDisplayBlock::MangaDetails => { 96 | DETAILS_NAVIGATION_KEYS.to_vec() 97 | } 98 | _ => NAVIGATION_KEYS.to_vec(), 99 | }; 100 | let key_chunks = Layout::default() 101 | .direction(Direction::Horizontal) 102 | .constraints( 103 | keys.iter() 104 | .map(|_| Constraint::Percentage(100 / keys.len() as u16)) 105 | .collect::>(), 106 | ) 107 | .split(keys_chunk); 108 | 109 | for (i, (key, description)) in keys.iter().enumerate() { 110 | let block = 111 | Paragraph::new(format!("{}: {}", key, description)).alignment(Alignment::Center); 112 | f.render_widget(block, key_chunks[i]); 113 | } 114 | 115 | display_chunk 116 | } 117 | 118 | pub fn get_anime_status_color(status: &UserWatchStatus, app: &App) -> Color { 119 | match status { 120 | UserWatchStatus::Completed => app.app_config.theme.status_completed, 121 | UserWatchStatus::Dropped => app.app_config.theme.status_dropped, 122 | UserWatchStatus::OnHold => app.app_config.theme.status_on_hold, 123 | UserWatchStatus::PlanToWatch => app.app_config.theme.status_plan_to_watch, 124 | UserWatchStatus::Watching => app.app_config.theme.status_watching, 125 | UserWatchStatus::Other(_) => app.app_config.theme.status_other, 126 | } 127 | } 128 | 129 | pub fn get_manga_status_color(status: &UserReadStatus, app: &App) -> Color { 130 | match status { 131 | UserReadStatus::Completed => app.app_config.theme.status_completed, 132 | UserReadStatus::Dropped => app.app_config.theme.status_dropped, 133 | UserReadStatus::OnHold => app.app_config.theme.status_on_hold, 134 | UserReadStatus::PlanToRead => app.app_config.theme.status_plan_to_watch, 135 | UserReadStatus::Reading => app.app_config.theme.status_watching, 136 | UserReadStatus::Other(_) => app.app_config.theme.status_other, 137 | } 138 | } 139 | 140 | pub fn center_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect { 141 | let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center); 142 | let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center); 143 | let [area] = vertical.areas(area); 144 | let [area] = horizontal.areas(area); 145 | area 146 | } 147 | 148 | // pub fn first_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect { 149 | // let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Start); 150 | // let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Start); 151 | // let [area] = vertical.areas(area); 152 | // let [area] = horizontal.areas(area); 153 | // area 154 | // } 155 | 156 | // pub fn last_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect { 157 | // let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::End); 158 | // let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::End); 159 | // let [area] = vertical.areas(area); 160 | // let [area] = horizontal.areas(area); 161 | // area 162 | // } 163 | 164 | fn draw_exit_confirmation_popup(f: &mut Frame, app: &App, chunk: Rect) { 165 | let popup_area = center_area(chunk, 100, 20); 166 | // get popup area and then get the exact length of the sentence 167 | let [popup_chunk] = Layout::default() 168 | .direction(Direction::Horizontal) 169 | .constraints([Constraint::Max(40)]) 170 | .flex(Flex::Center) 171 | .areas(popup_area); 172 | let [popup_chunk] = Layout::default() 173 | .direction(Direction::Vertical) 174 | .constraints([Constraint::Max(9)]) 175 | .flex(Flex::Center) 176 | .areas(popup_chunk); 177 | 178 | let block = Block::default() 179 | .borders(Borders::ALL) 180 | .border_type(BorderType::Rounded) 181 | .title(" Exit Confirmation ") 182 | .title_alignment(Alignment::Center) 183 | .title_style(Style::default().fg(app.app_config.theme.text)) 184 | .border_style(get_color(true, app.app_config.theme)); 185 | 186 | f.render_widget(Clear, popup_chunk); 187 | f.render_widget(block, popup_chunk); 188 | 189 | let [popup_message_chunk] = Layout::default() 190 | .direction(Direction::Vertical) 191 | .constraints([Constraint::Length(1)]) 192 | .flex(Flex::Center) 193 | .areas(popup_chunk); 194 | let message = Paragraph::new("Are you sure you want to exit ?").alignment(Alignment::Center); 195 | f.render_widget(message, popup_message_chunk); 196 | } 197 | -------------------------------------------------------------------------------- /src/ui/display_block/results.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use crate::api::model::MangaMediaType; 4 | use crate::api::model::Node; 5 | use crate::api::model::PageableData; 6 | use crate::api::model::UserReadStatus; 7 | use crate::app::DISPLAY_COLUMN_NUMBER; 8 | use crate::app::DISPLAY_RAWS_NUMBER; 9 | use crate::ui::format_number_with_commas; 10 | use crate::ui::get_end_card_index; 11 | use crate::{ 12 | api::model::{AnimeMediaType, UserWatchStatus}, 13 | app::{ActiveBlock, App, SelectedSearchTab}, 14 | ui::util::get_color, 15 | }; 16 | use ratatui::{ 17 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 18 | style::{Color, Modifier, Style}, 19 | text::{Line, Span}, 20 | widgets::{Block, Borders, Paragraph}, 21 | Frame, 22 | }; 23 | 24 | use super::get_anime_status_color; 25 | 26 | pub fn draw_results(f: &mut Frame, app: &App, chunk: Rect) { 27 | match app.search_results.selected_tab { 28 | SelectedSearchTab::Anime => { 29 | if app.search_results.anime.as_ref().is_some() { 30 | draw_anime_search_results(f, app, chunk); 31 | } else { 32 | // draw_no_results(f, app, chunk); 33 | } 34 | } 35 | SelectedSearchTab::Manga => { 36 | if app.search_results.manga.as_ref().is_some() { 37 | draw_manga_search_results(f, app, chunk); 38 | } else { 39 | // draw_no_results(f, app, chunk); 40 | } 41 | } 42 | } 43 | } 44 | 45 | pub fn draw_anime_search_results(f: &mut Frame, app: &App, chunk: Rect) { 46 | let results = app.search_results.anime.as_ref().unwrap(); 47 | if results.data.is_empty() { 48 | // draw_no_results(f, app, chunk); 49 | return; 50 | } 51 | let start_index = app.start_card_list_index as usize; 52 | let end_index = get_end_card_index(app); 53 | // let end_index = app.end_card_list_index as usize; 54 | 55 | let (cards, components_result) = construct_cards_with_data(chunk, results); 56 | // we need to calculate the end index carefully 57 | let component_page = components_result[start_index..=end_index].to_vec(); 58 | 59 | let selected_card_index = app.search_results.selected_display_card_index.unwrap_or(0); 60 | 61 | // let selected_card_index = 5; 62 | 63 | for (index, component) in component_page.iter().enumerate() { 64 | let is_active = 65 | index == selected_card_index && app.active_block == ActiveBlock::DisplayBlock; 66 | 67 | let anime_status = component 68 | .my_list_status 69 | .as_ref() 70 | .map_or(UserWatchStatus::Other("None".to_string()), |status| { 71 | status.status.clone() 72 | }); 73 | 74 | let anime_status_color = get_anime_status_color(&anime_status, app); 75 | 76 | let anime_status: &str = anime_status.into(); 77 | 78 | let title_style = get_color(is_active, app.app_config.theme); 79 | 80 | let anime_title = &component.get_title(&app.app_config, false)[0]; 81 | 82 | let title: Line<'_> = Line::from(vec![ 83 | Span::styled(anime_title, title_style.add_modifier(Modifier::BOLD)), 84 | Span::raw(" "), 85 | Span::styled(anime_status, Style::default().fg(anime_status_color)), 86 | ]); 87 | 88 | let media_type: &str = Into::<&str>::into( 89 | component 90 | .media_type 91 | .as_ref() 92 | .map_or(AnimeMediaType::Other("Unknown".to_string()), |media_type| { 93 | media_type.clone() 94 | }), 95 | ); 96 | 97 | let ep_num: String = component.num_episodes.map_or("N/A".to_string(), |ep| { 98 | if ep != 0 { 99 | ep.to_string() 100 | } else { 101 | "N/A".to_string() 102 | } 103 | }); 104 | 105 | let start_date: String = component 106 | .start_date 107 | .as_ref() 108 | .map_or("unknown".to_string(), |date| date.date.year().to_string()); 109 | 110 | let num_user_list: String = component 111 | .num_list_users 112 | .map_or("N/A".to_string(), format_number_with_commas); 113 | 114 | let score = Line::from(Span::styled( 115 | format!( 116 | "Scored {}", 117 | component.mean.map_or("N/A".to_string(), |m| m.to_string()) 118 | ), 119 | Style::default(), //? we can add a function to get color based on score 120 | )); 121 | 122 | let num_ep = Line::from(Span::styled( 123 | format!("{} ({} eps)", media_type, ep_num), 124 | app.app_config.theme.text, 125 | )); 126 | 127 | let start_date = Line::from(Span::styled(start_date, app.app_config.theme.text)); 128 | 129 | let num_user_list = Line::from(Span::styled( 130 | format!("{} members", num_user_list), 131 | app.app_config.theme.text, 132 | )); 133 | 134 | // if index >= cards.len() { 135 | // break; 136 | // 137 | 138 | let card = Paragraph::new(vec![title, num_ep, score, start_date, num_user_list]) 139 | .alignment(Alignment::Left) 140 | .wrap(ratatui::widgets::Wrap { trim: true }) 141 | .block( 142 | Block::default() 143 | .borders(Borders::ALL) 144 | .border_style(get_color(is_active, app.app_config.theme)), 145 | ); 146 | 147 | f.render_widget(card, cards[index]); 148 | } 149 | /* 150 | we are gonna display these fields: 151 | 1. title 152 | 2. average score 153 | 3. mean score 154 | 4. number of episodes 155 | 5. media type 156 | 6. start date 157 | 7. user status 158 | */ 159 | } 160 | 161 | pub fn draw_manga_search_results(f: &mut Frame, app: &App, chunk: Rect) { 162 | let results = app.search_results.manga.as_ref().unwrap(); 163 | if results.data.is_empty() { 164 | //TODO: handle no results 165 | // draw_no_results(f, app, chunk); 166 | return; 167 | } 168 | let start_index = app.start_card_list_index as usize; 169 | let end_index = get_end_card_index(app); 170 | let (cards, components_result) = construct_cards_with_data(chunk, results); 171 | let component_page = components_result[start_index..=end_index].to_vec(); 172 | 173 | let selected_card_index = app.search_results.selected_display_card_index.unwrap_or(0); 174 | 175 | for (index, component) in component_page.iter().enumerate() { 176 | if index >= cards.len() { 177 | break; 178 | } 179 | let is_active = 180 | index == selected_card_index && app.active_block == ActiveBlock::DisplayBlock; 181 | 182 | let manga_status = component 183 | .my_list_status 184 | .as_ref() 185 | .map_or(UserReadStatus::Other("None".to_string()), |status| { 186 | status.status.clone() 187 | }); 188 | 189 | let manga_status_color = get_manga_status_color(&manga_status, app); 190 | 191 | let title_style = get_color(is_active, app.app_config.theme); 192 | let title = &component.get_title(&app.app_config, false)[0]; 193 | 194 | let title: Line<'_> = Line::from(vec![ 195 | Span::styled(title, title_style.add_modifier(Modifier::BOLD)), 196 | Span::raw(" "), 197 | Span::styled::<&str, ratatui::prelude::Style>( 198 | manga_status.into(), 199 | Style::default().fg(manga_status_color), 200 | ), 201 | ]); 202 | 203 | let media_type: &str = Into::<&str>::into( 204 | component 205 | .media_type 206 | .as_ref() 207 | .map_or(MangaMediaType::Other("None".to_string()), |media_type| { 208 | media_type.clone() 209 | }), 210 | ); 211 | 212 | let vol_num: String = component.get_num(&app.app_config); 213 | let start_date: String = component 214 | .start_date 215 | .as_ref() 216 | .map_or("unknown".to_string(), |date| date.date.year().to_string()); 217 | 218 | let score = Line::from(Span::styled( 219 | format!( 220 | "Scored {}", 221 | component.mean.map_or("N/A".to_string(), |m| m.to_string()) 222 | ), 223 | Style::default(), //? we can add a function to get color based on score 224 | )); 225 | 226 | let num_user_list: String = component 227 | .num_list_users 228 | .map_or("N/A".to_string(), format_number_with_commas); 229 | 230 | let type_num_vol = Line::from(Span::styled( 231 | format!("{} ({})", media_type, vol_num), 232 | app.app_config.theme.text, 233 | )); 234 | 235 | let start_date = Line::from(Span::styled(start_date, app.app_config.theme.text)); 236 | 237 | let num_user_list = Line::from(Span::styled( 238 | format!("{} members", num_user_list), 239 | app.app_config.theme.text, 240 | )); 241 | 242 | let card = Paragraph::new(vec![title, type_num_vol, score, start_date, num_user_list]) 243 | .alignment(Alignment::Left) 244 | .wrap(ratatui::widgets::Wrap { trim: true }) 245 | .block( 246 | Block::default() 247 | .borders(Borders::ALL) 248 | .border_style(get_color(is_active, app.app_config.theme)), 249 | ); 250 | 251 | f.render_widget(card, cards[index]); 252 | } 253 | } 254 | 255 | pub fn construct_cards_with_data( 256 | chunk: Rect, 257 | results: &PageableData>>, 258 | ) -> (Vec, Vec<&T>) { 259 | let current_page = &results.data; 260 | 261 | let raw_layout = Layout::default() 262 | .direction(Direction::Vertical) 263 | .margin(1) 264 | .constraints(vec![Constraint::Percentage(20); DISPLAY_RAWS_NUMBER]) 265 | .split(chunk); 266 | 267 | let components: Vec<&T> = current_page.iter().map(|node| &node.node).collect(); 268 | 269 | ( 270 | raw_layout 271 | .iter() 272 | .flat_map(|raw| { 273 | Layout::default() 274 | .direction(Direction::Horizontal) 275 | .constraints(vec![ 276 | Constraint::Ratio(1, DISPLAY_COLUMN_NUMBER as u32); 277 | DISPLAY_COLUMN_NUMBER 278 | ]) 279 | .split(*raw) 280 | .iter() 281 | .copied() 282 | .collect::>() 283 | }) 284 | .collect(), 285 | components, 286 | ) 287 | } 288 | 289 | fn get_manga_status_color(status: &UserReadStatus, app: &App) -> Color { 290 | match status { 291 | UserReadStatus::Completed => app.app_config.theme.status_completed, 292 | UserReadStatus::Dropped => app.app_config.theme.status_dropped, 293 | UserReadStatus::OnHold => app.app_config.theme.status_on_hold, 294 | UserReadStatus::PlanToRead => app.app_config.theme.status_plan_to_watch, 295 | UserReadStatus::Reading => app.app_config.theme.status_watching, 296 | UserReadStatus::Other(_) => app.app_config.theme.status_other, 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/ui/display_block/search.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 3 | text::Span, 4 | widgets::{Block, Borders, Paragraph}, 5 | Frame, 6 | }; 7 | 8 | use crate::{ 9 | app::{App, SelectedSearchTab}, 10 | ui::util::get_color, 11 | }; 12 | 13 | use super::results::draw_results; 14 | 15 | pub fn draw_search_result(f: &mut Frame, app: &App, chunk: Rect) { 16 | let chunk = draw_nav_bar(f, app, chunk); 17 | let chunk = super::draw_keys_bar(f, app, chunk); 18 | 19 | draw_results(f, app, chunk); 20 | /* 21 | we get data as pages and display page by page,navigating through the pages with 22 | */ 23 | } 24 | 25 | pub fn draw_nav_bar(f: &mut Frame, app: &App, chunk: Rect) -> Rect { 26 | let splitted_layout: [Rect; 2] = Layout::default() 27 | .direction(Direction::Vertical) 28 | .constraints([Constraint::Percentage(7), Constraint::Percentage(93)]) 29 | .margin(0) 30 | .areas(chunk); 31 | 32 | let bar = splitted_layout[0]; 33 | 34 | let tab: [Rect; 2] = Layout::default() 35 | .direction(Direction::Horizontal) 36 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) 37 | .areas(bar); 38 | 39 | let anime_tab = tab[0]; 40 | 41 | let mut is_active = app.search_results.selected_tab == SelectedSearchTab::Anime; 42 | // handle toggle 43 | let anime = Span::styled("Anime", get_color(is_active, app.app_config.theme)); 44 | let anime_tab_paragraph = Paragraph::new(anime).alignment(Alignment::Center).block( 45 | Block::default() 46 | .borders(Borders::ALL) 47 | .border_style(get_color(is_active, app.app_config.theme)), 48 | ); 49 | 50 | f.render_widget(anime_tab_paragraph, anime_tab); 51 | 52 | let manga_tab = tab[1]; 53 | 54 | is_active = app.search_results.selected_tab == SelectedSearchTab::Manga; 55 | 56 | let manga = Span::styled("Manga", get_color(is_active, app.app_config.theme)); 57 | let manga_tab_block = Paragraph::new(manga).alignment(Alignment::Center).block( 58 | Block::default() 59 | .borders(Borders::ALL) 60 | .border_style(get_color(is_active, app.app_config.theme)), 61 | ); 62 | // .block(block); 63 | 64 | f.render_widget(manga_tab_block, manga_tab); 65 | splitted_layout[1] 66 | } 67 | -------------------------------------------------------------------------------- /src/ui/display_block/seasonal.rs: -------------------------------------------------------------------------------- 1 | use super::center_area; 2 | use super::{draw_keys_bar, results}; 3 | use crate::app::{App, SEASONS}; 4 | use ratatui::layout::{Alignment, Constraint, Direction, Flex, Layout}; 5 | use ratatui::style::{Modifier, Style}; 6 | use ratatui::text::Line; 7 | use ratatui::widgets::{Clear, List, ListState, Padding}; 8 | use ratatui::{ 9 | layout::Rect, 10 | widgets::{Block, BorderType, Borders}, 11 | Frame, 12 | }; 13 | 14 | pub fn draw_seasonal_anime(f: &mut Frame, app: &App, chunk: Rect) { 15 | let chunk = draw_keys_bar(f, app, chunk); 16 | results::draw_results(f, app, chunk); 17 | if app.popup { 18 | draw_seasonal_popup(f, app, chunk); 19 | } 20 | } 21 | 22 | fn draw_seasonal_popup(f: &mut Frame, app: &App, chunk: Rect) { 23 | let area = center_area(chunk, 30, 50); 24 | 25 | let popup = Block::default() 26 | .title("Select Season") 27 | .title_alignment(Alignment::Center) 28 | .borders(Borders::ALL) 29 | .border_type(BorderType::Double); 30 | 31 | f.render_widget(Clear, area); 32 | f.render_widget(popup, area); 33 | 34 | let is_popup_season_block = app.anime_season.popup_season_highlight; 35 | 36 | let [season_chunk, year_chunk] = Layout::default() 37 | .constraints(vec![Constraint::Percentage(50); 2]) 38 | .margin(2) 39 | .direction(Direction::Horizontal) 40 | .areas(area); 41 | 42 | // ===> season 43 | let mut season_block = Block::default() 44 | .title_alignment(Alignment::Center) 45 | .borders(Borders::NONE) 46 | .padding(Padding::symmetric(1, 1)); 47 | 48 | if is_popup_season_block { 49 | season_block = season_block 50 | .title_style( 51 | Style::default() 52 | .add_modifier(Modifier::UNDERLINED) 53 | .add_modifier(Modifier::BOLD), 54 | ) 55 | .title("Season") 56 | } else { 57 | season_block = season_block.title("Season") 58 | } 59 | 60 | let list: Vec = SEASONS 61 | .iter() 62 | .map(|s| { 63 | Line::from(*s) 64 | .alignment(Alignment::Center) 65 | .style(Style::default().fg(app.app_config.theme.text)) 66 | }) 67 | .collect(); 68 | let season_selected: Option = Some(app.anime_season.selected_season.into()); 69 | 70 | let mut state = ListState::default(); 71 | state.select(season_selected); 72 | 73 | let season_list = List::new(list).block(season_block).highlight_style( 74 | Style::default() 75 | .fg(app.app_config.theme.selected) 76 | .add_modifier(Modifier::BOLD), 77 | ); 78 | 79 | let [centered_season_chunk] = Layout::default() 80 | .direction(Direction::Vertical) 81 | .constraints([Constraint::Length(7)]) 82 | .flex(Flex::Center) 83 | .areas(season_chunk); 84 | f.render_stateful_widget(season_list, centered_season_chunk, &mut state); 85 | 86 | // ===> year 87 | 88 | let mut year_block = Block::default() 89 | .title_alignment(Alignment::Center) 90 | .borders(Borders::NONE) 91 | .padding(Padding::symmetric(1, 1)); 92 | 93 | if !is_popup_season_block { 94 | year_block = year_block 95 | .title_style( 96 | Style::default() 97 | .add_modifier(Modifier::UNDERLINED) 98 | .add_modifier(Modifier::BOLD), 99 | ) 100 | .title("Year") 101 | } else { 102 | year_block = year_block.title("Year") 103 | } 104 | 105 | let list: Vec = vec![ 106 | (app.anime_season.selected_year + 1).to_string(), 107 | (app.anime_season.selected_year).to_string(), 108 | (app.anime_season.selected_year - 1).to_string(), 109 | ] 110 | .into_iter() 111 | .map(|s| { 112 | if s == app.anime_season.selected_year.to_string() { 113 | return Line::raw(s) 114 | .alignment(Alignment::Center) 115 | .style(Style::default().fg(app.app_config.theme.selected)); 116 | } 117 | Line::raw(s) 118 | .alignment(Alignment::Center) 119 | .style(Style::default().fg(app.app_config.theme.text)) 120 | }) 121 | .collect(); 122 | 123 | let year_list = List::new(list).block(year_block).highlight_style( 124 | Style::default() 125 | .fg(app.app_config.theme.selected) 126 | .add_modifier(Modifier::BOLD), 127 | ); 128 | 129 | let [centered_year_chunk] = Layout::default() 130 | .direction(Direction::Vertical) 131 | .constraints([Constraint::Length(7)]) 132 | .flex(Flex::Center) 133 | .areas(year_chunk); 134 | f.render_widget(year_list, centered_year_chunk); 135 | } 136 | -------------------------------------------------------------------------------- /src/ui/display_block/suggestion.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{layout::Rect, Frame}; 2 | 3 | use crate::app::App; 4 | 5 | use super::{draw_keys_bar, results}; 6 | 7 | pub fn draw_suggestions(f: &mut Frame, app: &App, chunk: Rect) { 8 | let chunk = draw_keys_bar(f, app, chunk); 9 | results::draw_anime_search_results(f, app, chunk); 10 | } 11 | -------------------------------------------------------------------------------- /src/ui/display_block/user.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Alignment, Constraint, Direction, Flex, Layout, Rect}, 3 | style::{Modifier, Style}, 4 | text::{Line, Span}, 5 | widgets::{Block, BorderType, Borders, Gauge, Paragraph, Wrap}, 6 | Frame, 7 | }; 8 | 9 | use crate::app::App; 10 | 11 | use super::{center_area, empty::draw_figlet}; 12 | 13 | pub fn draw_user_info(f: &mut Frame, app: &App, chunk: Rect) { 14 | let [username_chunk, info_chunk] = Layout::default() 15 | .direction(Direction::Vertical) 16 | .constraints([Constraint::Length(10), Constraint::Fill(1)]) 17 | .areas(chunk); 18 | 19 | // draw border: 20 | let block = Block::default().borders(Borders::BOTTOM); 21 | f.render_widget(block, center_area(username_chunk, 95, 100)); 22 | 23 | let username = app.user_profile.as_ref().unwrap().name.clone(); 24 | draw_figlet(f, username, username_chunk, app); 25 | // extracting data: 26 | let gauge_chunk = draw_info(f, app, info_chunk); 27 | let block = Block::default().borders(Borders::RIGHT); 28 | f.render_widget(block, center_area(gauge_chunk, 100, 95)); 29 | draw_gauges(f, app, gauge_chunk); 30 | } 31 | 32 | fn draw_gauges(f: &mut Frame, app: &App, layout: Rect) { 33 | let stats = app.user_profile.as_ref().unwrap().anime_statistics.as_ref(); 34 | if stats.is_none() { 35 | draw_no_stats(f, app, layout); 36 | return; 37 | } 38 | let stats = stats.unwrap().clone(); 39 | 40 | let watching = stats.num_items_watching; 41 | let completed = stats.num_items_completed; 42 | let on_hold = stats.num_items_on_hold; 43 | let dropped = stats.num_items_dropped; 44 | let plan_to_watch = stats.num_items_plan_to_watch; 45 | let total_entries = stats.num_items; 46 | let layout: [Rect; 5] = Layout::default() 47 | .direction(Direction::Vertical) 48 | .constraints([Constraint::Length(3); 5]) 49 | .flex(Flex::SpaceAround) 50 | .areas(center_area(layout, 70, 80)); 51 | 52 | let block = Block::default() 53 | .border_type(BorderType::Plain) 54 | .borders(Borders::ALL); 55 | 56 | let watching_title = Paragraph::new(format!("Watching: {}", watching)) 57 | .style(Style::default().fg(app.app_config.theme.text)) 58 | .alignment(Alignment::Left) 59 | .wrap(Wrap { trim: true }); 60 | let watching_gauge = Gauge::default() 61 | .block(block.clone()) 62 | .gauge_style(app.app_config.theme.status_watching) 63 | .ratio(watching as f64 / total_entries as f64) 64 | .label(format!( 65 | "{:.0}%", 66 | (watching as f64 / total_entries as f64) * 100.0 67 | )); 68 | 69 | let completed_title = Paragraph::new(format!("Completed: {}", completed)) 70 | .style(Style::default().fg(app.app_config.theme.text)) 71 | .alignment(Alignment::Left) 72 | .wrap(Wrap { trim: true }); 73 | let completed_gauge = Gauge::default() 74 | .block(block.clone()) 75 | .gauge_style(app.app_config.theme.status_watching) 76 | .gauge_style(app.app_config.theme.status_completed) 77 | .ratio(completed as f64 / total_entries as f64) 78 | .label(format!( 79 | "{:.0}%", 80 | (completed as f64 / total_entries as f64) * 100.0 81 | )); 82 | 83 | let on_hold_title = Paragraph::new(format!("On Hold: {}", on_hold)) 84 | .style(Style::default().fg(app.app_config.theme.text)) 85 | .alignment(Alignment::Left) 86 | .wrap(Wrap { trim: true }); 87 | 88 | let on_hold_gauge = Gauge::default() 89 | .block(block.clone()) 90 | .gauge_style(app.app_config.theme.status_watching) 91 | .gauge_style(app.app_config.theme.status_on_hold) 92 | .ratio(on_hold as f64 / total_entries as f64) 93 | .label(format!( 94 | "{:.0}%", 95 | (on_hold as f64 / total_entries as f64) * 100.0 96 | )); 97 | 98 | let dropped_title = Paragraph::new(format!("Dropped: {}", dropped)) 99 | .style(Style::default().fg(app.app_config.theme.text)) 100 | .alignment(Alignment::Left) 101 | .wrap(Wrap { trim: true }); 102 | 103 | let dropped_gauge = Gauge::default() 104 | .block(block.clone()) 105 | .gauge_style(app.app_config.theme.status_watching) 106 | .gauge_style(app.app_config.theme.status_dropped) 107 | .ratio(dropped as f64 / total_entries as f64) 108 | .label(format!( 109 | "{:.0}%", 110 | (dropped as f64 / total_entries as f64) * 100.0 111 | )); 112 | let plan_to_watch_title = Paragraph::new(format!("Plan to Watch: {}", plan_to_watch)) 113 | .style(Style::default().fg(app.app_config.theme.text)) 114 | .alignment(Alignment::Left) 115 | .wrap(Wrap { trim: true }); 116 | 117 | let plan_to_watch_gauge = Gauge::default() 118 | .block(block.clone()) 119 | .gauge_style(app.app_config.theme.status_plan_to_watch) 120 | .ratio(plan_to_watch as f64 / total_entries as f64) 121 | .label(format!( 122 | "{:.0}%", 123 | (plan_to_watch as f64 / total_entries as f64) * 100.0 124 | )); 125 | 126 | let percent_y = 40; 127 | let percent_x = 100; 128 | let chunk = get_stat_chunk(layout[0]); 129 | f.render_widget(watching_title, center_area(chunk[0], percent_x, percent_y)); 130 | f.render_widget(watching_gauge, chunk[1]); 131 | 132 | let chunk = get_stat_chunk(layout[1]); 133 | f.render_widget(completed_title, center_area(chunk[0], percent_x, percent_y)); 134 | f.render_widget(completed_gauge, chunk[1]); 135 | 136 | let chunk = get_stat_chunk(layout[2]); 137 | f.render_widget(on_hold_title, center_area(chunk[0], percent_x, percent_y)); 138 | f.render_widget(on_hold_gauge, chunk[1]); 139 | 140 | let chunk = get_stat_chunk(layout[3]); 141 | f.render_widget(dropped_title, center_area(chunk[0], percent_x, percent_y)); 142 | f.render_widget(dropped_gauge, chunk[1]); 143 | 144 | let chunk = get_stat_chunk(layout[4]); 145 | f.render_widget( 146 | plan_to_watch_title, 147 | center_area(chunk[0], percent_x, percent_y), 148 | ); 149 | f.render_widget(plan_to_watch_gauge, chunk[1]); 150 | } 151 | 152 | fn get_stat_chunk(layout: Rect) -> [Rect; 2] { 153 | Layout::default() 154 | .direction(Direction::Horizontal) 155 | .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) 156 | .areas(layout) 157 | } 158 | 159 | fn draw_no_stats(f: &mut Frame, app: &App, chunk: Rect) { 160 | let text = "No statistics available !! Add some anime to your list!"; 161 | 162 | let line = Line::from(Span::styled( 163 | text, 164 | Style::default().fg(app.app_config.theme.text), 165 | )); 166 | let paragraph = Paragraph::new(line) 167 | .alignment(Alignment::Center) 168 | .wrap(Wrap { trim: true }); 169 | let centered_chunk = center_area(chunk, 100, 20); 170 | f.render_widget(paragraph, centered_chunk); 171 | } 172 | 173 | fn draw_info(f: &mut Frame, app: &App, chunk: Rect) -> Rect { 174 | let layout: [Rect; 2] = Layout::default() 175 | .direction(Direction::Horizontal) 176 | .constraints([Constraint::Percentage(100), Constraint::Min(35)]) 177 | .areas(chunk); 178 | 179 | let joined_at = app.user_profile.as_ref().unwrap().joined_at.clone(); 180 | let mut total_items = 0; 181 | let mut mean_score = 0.0; 182 | let mut total_eps = 0; 183 | let mut total_days = 0.0; 184 | let stats = app.user_profile.as_ref().unwrap().anime_statistics.as_ref(); 185 | if stats.is_some() { 186 | total_items = stats.unwrap().num_items; 187 | mean_score = stats.unwrap().mean_score; 188 | total_days = stats.unwrap().num_days; 189 | total_eps = stats.unwrap().num_episodes; 190 | } 191 | 192 | let location = app 193 | .user_profile 194 | .as_ref() 195 | .unwrap() 196 | .location 197 | .clone() 198 | .unwrap_or("".to_string()); 199 | 200 | let mut list = vec![ 201 | ( 202 | "Joined at: ".to_string(), 203 | joined_at.datetime.date().to_string(), 204 | ), 205 | ("Total Entries: ".to_string(), total_items.to_string()), 206 | ]; 207 | if mean_score != 0.0 { 208 | let mean_score = format!("{:.2}", mean_score); 209 | list.push(("Mean Score: ".to_string(), mean_score)); 210 | } 211 | if total_eps != 0 { 212 | list.push(("Total Episodes: ".to_string(), format!("{} Eps", total_eps))); 213 | } 214 | if total_days != 0.0 { 215 | let total_days = { 216 | let hours = ((total_days) * 24.0).floor(); 217 | format!(" {:.0} hours", hours) 218 | }; 219 | list.push(("Total Time: ".to_string(), total_days)); 220 | } 221 | 222 | if !location.is_empty() { 223 | list.push(("Location: ".to_string(), location)); 224 | } 225 | let birthday = app 226 | .user_profile 227 | .as_ref() 228 | .unwrap() 229 | .birthday 230 | .clone() 231 | .map_or("".to_string(), |b| b.date.to_string()); 232 | 233 | let gender = app 234 | .user_profile 235 | .as_ref() 236 | .unwrap() 237 | .gender 238 | .clone() 239 | .map_or("".to_string(), |g| g.to_string()); 240 | 241 | let is_supperter = app.user_profile.as_ref().unwrap().is_supporter; 242 | 243 | if !gender.is_empty() { 244 | list.push(("Gender: ".to_string(), gender)); 245 | } 246 | if !birthday.is_empty() { 247 | list.push(("Birthday: ".to_string(), birthday)); 248 | } 249 | 250 | if is_supperter.is_none() && is_supperter.unwrap() { 251 | list.push(("Supporter: ".to_string(), "Yes".to_string())); 252 | } 253 | let chunks = Layout::default() 254 | .direction(Direction::Vertical) 255 | .constraints( 256 | list.iter() 257 | .map(|_| Constraint::Percentage(100 / list.len() as u16)) 258 | .collect::>(), 259 | ) 260 | .split(center_area(layout[1], 90, 80)); 261 | for (i, item) in list.iter().enumerate() { 262 | let attr = Span::styled( 263 | item.0.clone(), 264 | Style::default() 265 | .fg(app.app_config.theme.text) 266 | .add_modifier(Modifier::BOLD), 267 | ); 268 | let item = Span::styled( 269 | item.1.clone(), 270 | Style::default().fg(app.app_config.theme.active), 271 | ); 272 | let line = Line::from(vec![attr, item]); 273 | let paragraph = Paragraph::new(line).alignment(Alignment::Left); 274 | f.render_widget(paragraph, chunks[i]); 275 | } 276 | 277 | layout[0] 278 | } 279 | -------------------------------------------------------------------------------- /src/ui/display_block/user_anime_list.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Alignment, Constraint, Direction, Flex, Layout, Rect}, 3 | text::{Line, Span}, 4 | widgets::{Block, BorderType, Borders, Paragraph, Wrap}, 5 | Frame, 6 | }; 7 | 8 | use crate::{ 9 | api::model::{AnimeMediaType, UserReadStatus, UserWatchStatus}, 10 | app::{ActiveBlock, App}, 11 | config::app_config::Theme, 12 | ui::util::get_color, 13 | }; 14 | 15 | use super::results::construct_cards_with_data; 16 | 17 | pub fn draw_user_anime_list(f: &mut Frame, app: &App, chunk: Rect) { 18 | // order matters, it should be the same as the Status enum 19 | let statuses = vec![ 20 | "add", 21 | "watching", 22 | "completed", 23 | "on_hold", 24 | "dropped", 25 | "plan_to_watch", 26 | ]; 27 | 28 | let chunk = draw_user_list_nav_bar(f, app, chunk, true, statuses); 29 | 30 | let chunk = super::draw_keys_bar(f, app, chunk); 31 | draw_anime_list_results(f, app, chunk); 32 | } 33 | 34 | pub fn draw_user_list_nav_bar( 35 | f: &mut Frame, 36 | app: &App, 37 | chunk: Rect, 38 | is_anime: bool, 39 | status_list: Vec<&str>, 40 | ) -> Rect { 41 | let layout: [Rect; 2] = Layout::default() 42 | .direction(Direction::Vertical) 43 | .constraints([Constraint::Percentage(7), Constraint::Percentage(93)]) 44 | .margin(0) 45 | .areas(chunk); 46 | 47 | let bar = layout[0]; 48 | 49 | let block = Block::default().border_style(app.app_config.theme.active); 50 | f.render_widget(block, bar); 51 | 52 | let tabs = Layout::default() 53 | .direction(Direction::Horizontal) 54 | .constraints(vec![ 55 | Constraint::Percentage(100 / status_list.len() as u16); 56 | status_list.len() 57 | ]) 58 | .flex(Flex::SpaceAround) 59 | .split(bar); 60 | 61 | for (i, status) in status_list.iter().enumerate() { 62 | let is_active = if is_anime { 63 | eq_anime_status(&app.anime_list_status, status) 64 | } else { 65 | eq_manga_status(&app.manga_list_status, status) 66 | }; 67 | let status = get_status_title(status, is_anime); 68 | draw_tab(f, tabs[i], status, is_active, app.app_config.theme); 69 | } 70 | 71 | layout[1] 72 | } 73 | 74 | pub fn get_status_title(status: &str, is_anime: bool) -> String { 75 | match status { 76 | "watching" => "Watching".to_string(), 77 | "completed" => "Completed".to_string(), 78 | "on_hold" => "On Hold".to_string(), 79 | "dropped" => "Dropped".to_string(), 80 | "plan_to_watch" => "Plan To Watch".to_string(), 81 | "plan_to_read" => "Plan To Read".to_string(), 82 | "reading" => "Reading".to_string(), 83 | "add" => { 84 | if is_anime { 85 | "All Anime".to_string() 86 | } else { 87 | "All Manga".to_string() 88 | } 89 | } 90 | _ => status.to_string(), 91 | } 92 | } 93 | 94 | pub fn draw_tab(f: &mut Frame, tab_chunk: Rect, content: String, is_active: bool, theme: Theme) { 95 | let content_span = Span::styled(content, get_color(is_active, theme)); 96 | let paragraph = Paragraph::new(content_span) 97 | .alignment(Alignment::Center) 98 | .block( 99 | Block::default() 100 | .borders(Borders::ALL) 101 | .border_style(get_color(is_active, theme)), 102 | ); 103 | 104 | f.render_widget(paragraph, tab_chunk); 105 | } 106 | 107 | fn draw_anime_list_results(f: &mut Frame, app: &App, chunk: Rect) { 108 | let results = app.search_results.anime.as_ref().unwrap(); 109 | if results.data.is_empty() { 110 | // draw_no_results(f, app, chunk); 111 | return; 112 | } 113 | let cards_results = construct_cards_with_data(chunk, results); 114 | 115 | let cards = cards_results.0; 116 | let components = cards_results.1; 117 | 118 | let selected_card_index = app.search_results.selected_display_card_index.unwrap_or(0); 119 | 120 | for (index, component) in components.iter().enumerate() { 121 | if index >= cards.len() { 122 | break; 123 | } 124 | 125 | let is_active = 126 | index == selected_card_index && app.active_block == ActiveBlock::DisplayBlock; 127 | 128 | let title_style = get_color(is_active, app.app_config.theme); 129 | 130 | let anime_title = &component.get_title(&app.app_config, false)[0]; 131 | 132 | let anime_title = Line::styled(anime_title, title_style); 133 | 134 | let media_type: &str = Into::<&str>::into( 135 | component 136 | .media_type 137 | .as_ref() 138 | .map_or(AnimeMediaType::Other("Unknown".to_string()), |media_type| { 139 | media_type.clone() 140 | }), 141 | ); 142 | 143 | let score = component 144 | .my_list_status 145 | .as_ref() 146 | .map_or("-".to_string(), |status| { 147 | if status.score.to_string() != "0" { 148 | status.score.to_string() 149 | } else { 150 | "-".to_string() 151 | } 152 | }); 153 | 154 | let anime_type_and_score = Line::from(format!("{} {}", media_type, score)); 155 | 156 | let tags = Line::from( 157 | component 158 | .my_list_status 159 | .as_ref() 160 | .map_or("".to_string(), |status| { 161 | status 162 | .tags 163 | .as_ref() 164 | .map_or("".to_string(), |tags| tags.join(", ")) 165 | }), 166 | ); 167 | let anime_info = vec![anime_title, anime_type_and_score, tags]; 168 | let paragraph = Paragraph::new(anime_info) 169 | .block( 170 | Block::default() 171 | .borders(Borders::ALL) 172 | .border_type(BorderType::Rounded) 173 | .border_style(get_color(is_active, app.app_config.theme)), 174 | ) 175 | .alignment(Alignment::Left) 176 | .wrap(Wrap { trim: true }); 177 | 178 | f.render_widget(paragraph, cards[index]); 179 | } 180 | } 181 | 182 | fn eq_manga_status(status: &Option, status_str: &str) -> bool { 183 | match status { 184 | Some(UserReadStatus::Reading) => status_str == "reading", 185 | Some(UserReadStatus::Completed) => status_str == "completed", 186 | Some(UserReadStatus::OnHold) => status_str == "on_hold", 187 | Some(UserReadStatus::Dropped) => status_str == "dropped", 188 | Some(UserReadStatus::PlanToRead) => status_str == "plan_to_read", 189 | None => status_str == "add", 190 | _ => false, 191 | } 192 | } 193 | 194 | fn eq_anime_status(status: &Option, status_str: &str) -> bool { 195 | match status { 196 | Some(UserWatchStatus::Watching) => status_str == "watching", 197 | Some(UserWatchStatus::Completed) => status_str == "completed", 198 | Some(UserWatchStatus::OnHold) => status_str == "on_hold", 199 | Some(UserWatchStatus::Dropped) => status_str == "dropped", 200 | Some(UserWatchStatus::PlanToWatch) => status_str == "plan_to_watch", 201 | None => status_str == "add", 202 | _ => false, 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/ui/display_block/user_manga_list.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Alignment, Rect}, 3 | text::Line, 4 | widgets::{Block, BorderType, Borders, Paragraph, Wrap}, 5 | Frame, 6 | }; 7 | 8 | use crate::{ 9 | api::model::MangaMediaType, 10 | app::{ActiveBlock, App}, 11 | ui::util::get_color, 12 | }; 13 | 14 | use super::{results::construct_cards_with_data, user_anime_list::draw_user_list_nav_bar}; 15 | 16 | pub fn draw_user_manga_list(f: &mut Frame, app: &App, chunk: Rect) { 17 | let statuses = vec![ 18 | "add", 19 | "reading", 20 | "completed", 21 | "on_hold", 22 | "dropped", 23 | "plan_to_read", 24 | ]; 25 | let chunk = draw_user_list_nav_bar(f, app, chunk, false, statuses); 26 | let chunk = super::draw_keys_bar(f, app, chunk); 27 | draw_manga_list_results(f, app, chunk); 28 | } 29 | 30 | fn draw_manga_list_results(f: &mut Frame, app: &App, chunk: Rect) { 31 | let results = app.search_results.manga.as_ref().unwrap(); 32 | if results.data.is_empty() { 33 | return; 34 | } 35 | let cards_results = construct_cards_with_data(chunk, results); 36 | 37 | let cards = cards_results.0; 38 | let components = cards_results.1; 39 | let selected_card_index = app.search_results.selected_display_card_index.unwrap_or(0); 40 | 41 | for (index, component) in components.iter().enumerate() { 42 | if index >= cards.len() { 43 | break; 44 | } 45 | 46 | let is_active = 47 | index == selected_card_index && app.active_block == ActiveBlock::DisplayBlock; 48 | let title_style = get_color(is_active, app.app_config.theme); 49 | 50 | let manga_title = &component.get_title(&app.app_config, false)[0]; 51 | let manga_title = Line::styled(manga_title, title_style); 52 | 53 | let media_type: &str = Into::<&str>::into( 54 | component 55 | .media_type 56 | .as_ref() 57 | .map_or(MangaMediaType::Other("Unknown".to_string()), |media_type| { 58 | media_type.clone() 59 | }), 60 | ); 61 | 62 | let score = component 63 | .my_list_status 64 | .as_ref() 65 | .map_or("-".to_string(), |status| { 66 | if status.score.to_string() != "0" { 67 | status.score.to_string() 68 | } else { 69 | "-".to_string() 70 | } 71 | }); 72 | 73 | let manga_type_and_score = Line::from(format!("{} {}", media_type, score)); 74 | 75 | let tags = Line::from( 76 | component 77 | .my_list_status 78 | .as_ref() 79 | .map_or("".to_string(), |status| { 80 | status 81 | .tags 82 | .as_ref() 83 | .map_or("".to_string(), |tags| tags.join(", ")) 84 | }), 85 | ); 86 | 87 | let manga_info = vec![manga_title, manga_type_and_score, tags]; 88 | let paragraph = Paragraph::new(manga_info) 89 | .block( 90 | Block::default() 91 | .borders(Borders::ALL) 92 | .border_type(BorderType::Rounded) 93 | .border_style(get_color(is_active, app.app_config.theme)), 94 | ) 95 | .alignment(Alignment::Left) 96 | .wrap(Wrap { trim: true }); 97 | 98 | f.render_widget(paragraph, cards[index]); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/ui/help.rs: -------------------------------------------------------------------------------- 1 | use crate::app::App; 2 | use ratatui::{ 3 | layout::{Constraint, Direction, Layout}, 4 | style::Style, 5 | text::Span, 6 | widgets::{Block, Borders, Cell, Row, Table}, 7 | Frame, 8 | }; 9 | 10 | pub fn draw_help_menu(f: &mut Frame, app: &App) { 11 | let chunks = Layout::default() 12 | .direction(Direction::Vertical) 13 | .constraints([Constraint::Percentage(100)].as_ref()) 14 | .margin(2) 15 | .split(f.area()); 16 | 17 | let white = Style::default().fg(app.app_config.theme.text); 18 | let gray = Style::default().fg(app.app_config.theme.inactive); // 19 | 20 | let header = ["Description", "Event", "Context"]; 21 | let help_docs = get_help(); 22 | let help_docs: &[Vec<&str>] = &help_docs[app.help_menu_offset as usize..]; 23 | 24 | let rows: Vec = help_docs 25 | .iter() 26 | .map(|i| -> Row { 27 | Row::new( 28 | i.iter() 29 | .map(|&cell| -> Cell { Cell::from(cell).style(gray) }) 30 | .collect::>(), 31 | ) 32 | }) 33 | .collect::>(); 34 | 35 | let header = Row::new( 36 | header 37 | .iter() 38 | .map(|&header| Cell::from(header).style(white)) 39 | .collect::>(), 40 | ); 41 | 42 | let help_menu = Table::default() 43 | .rows(rows) 44 | .header(header) 45 | .block( 46 | Block::default() 47 | .borders(Borders::ALL) 48 | .style(white) 49 | .title(Span::styled("Help (press to go back)", gray)) 50 | .border_style(gray), 51 | ) 52 | .style(Style::default().fg(app.app_config.theme.text)) 53 | .widths([ 54 | Constraint::Length(50), 55 | Constraint::Length(40), 56 | Constraint::Length(20), 57 | ]); 58 | 59 | f.render_widget(help_menu, chunks[0]); 60 | } 61 | 62 | pub fn get_help() -> Vec> { 63 | // TODO: Help docs 64 | vec![vec!["Down", "j", "Pagination"]] 65 | } 66 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod help; 2 | mod side_menu; 3 | mod top_three; 4 | pub mod util; 5 | use crate::app::*; 6 | use ratatui::{ 7 | layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, 8 | style::Style, 9 | text::{Line, Span}, 10 | widgets::{Block, BorderType, Borders, Paragraph}, 11 | Frame, 12 | }; 13 | use util::get_color; 14 | mod display_block; 15 | 16 | pub fn draw_main_layout(f: &mut Frame, app: &mut App) { 17 | let margin = util::get_main_layout_margin(app); 18 | let app_area; 19 | if app.app_config.behavior.show_logger { 20 | let logger_area; 21 | [app_area, logger_area] = Layout::default() 22 | .direction(ratatui::layout::Direction::Horizontal) 23 | .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) 24 | .areas(f.area()); 25 | app.render_logs(f, logger_area); 26 | } else { 27 | app_area = f.area(); 28 | } 29 | 30 | // draw the logger area 31 | 32 | let parent_layout = Layout::default() 33 | .direction(Direction::Vertical) 34 | .constraints([Constraint::Length(3), Constraint::Min(1)].as_ref()) 35 | .margin(margin) 36 | .split(app_area); 37 | 38 | // Search Input and help 39 | draw_input_and_help_box(f, app, parent_layout[0]); 40 | 41 | // draw side and dipsplay sections 42 | let chunk = side_menu::draw_routes(f, app, parent_layout[1]); 43 | display_block::draw_display_layout(f, app, chunk); 44 | } 45 | 46 | pub fn draw_input_and_help_box(f: &mut Frame, app: &App, layout_chunk: Rect) { 47 | let [search_chunk, title_chunk] = Layout::default() 48 | .direction(Direction::Horizontal) 49 | .constraints([Constraint::Percentage(18), Constraint::Percentage(82)]) 50 | .areas(layout_chunk); 51 | // removing the little gap 52 | let search_chunk = search_chunk.inner(Margin::new(1, 0)); 53 | let current_block = app.active_block; 54 | 55 | let highlight_state = current_block == ActiveBlock::Input; 56 | 57 | let input_string: String = app.input.iter().collect(); 58 | let lines = Span::from(input_string); 59 | let input = Paragraph::new(lines).block( 60 | Block::default() 61 | .borders(Borders::ALL) 62 | .border_type(BorderType::Rounded) 63 | .title(Span::styled( 64 | "Search", 65 | get_color(highlight_state, app.app_config.theme), 66 | )) 67 | .border_style(get_color(highlight_state, app.app_config.theme)), 68 | ); 69 | f.render_widget(input, search_chunk); 70 | 71 | let mut title = app.display_block_title.clone(); 72 | if title.is_empty() { 73 | title = "Home".to_string(); // Default title , since i couldn't initialize it in app.rs:15 74 | } 75 | let block = Block::default() 76 | .borders(Borders::ALL) 77 | .border_type(BorderType::Rounded) 78 | .border_style(Style::default().fg(app.app_config.theme.inactive)); 79 | 80 | let lines = Line::from(Span::from(title)) 81 | .alignment(Alignment::Center) 82 | .style(Style::default().fg(app.app_config.theme.banner)); 83 | 84 | let help = Paragraph::new(lines) 85 | .block(block) 86 | .alignment(Alignment::Center) 87 | .style(Style::default().fg(app.app_config.theme.banner)); 88 | f.render_widget(help, title_chunk); 89 | } 90 | 91 | pub fn format_number_with_commas(number: u64) -> String { 92 | let num_str = number.to_string(); 93 | let mut result = String::new(); 94 | let mut count = 0; 95 | 96 | for c in num_str.chars().rev() { 97 | if count == 3 { 98 | result.push(','); 99 | count = 0; 100 | } 101 | result.push(c); 102 | count += 1; 103 | } 104 | 105 | result.chars().rev().collect() 106 | } 107 | 108 | fn get_end_index(app: &App, typ: &str) -> usize { 109 | match typ { 110 | "anime" => { 111 | let data_len = app.search_results.anime.as_ref().unwrap().data.len(); 112 | if app.start_card_list_index as usize + DISPLAY_RAWS_NUMBER * DISPLAY_COLUMN_NUMBER 113 | > data_len - 1 114 | // end is bigger than last index 115 | { 116 | data_len - 1 117 | } else { 118 | // start index + 14 to get the last index 119 | app.start_card_list_index as usize + DISPLAY_COLUMN_NUMBER * DISPLAY_RAWS_NUMBER - 1 120 | } 121 | } 122 | "manga" => { 123 | let data_len = app.search_results.manga.as_ref().unwrap().data.len(); 124 | if app.start_card_list_index as usize + DISPLAY_RAWS_NUMBER * DISPLAY_COLUMN_NUMBER 125 | > data_len - 1 126 | // end is bigger than last index in the data 127 | { 128 | data_len - 1 129 | } else { 130 | // start index + 14 to get the last index 131 | app.start_card_list_index as usize + DISPLAY_COLUMN_NUMBER * DISPLAY_RAWS_NUMBER - 1 132 | } 133 | } 134 | //TODO: handle these cases: 135 | "anime_ranking" => { 136 | let data_len = app.anime_ranking_data.as_ref().unwrap().data.len(); 137 | if app.start_card_list_index as usize + DISPLAY_RAWS_NUMBER * DISPLAY_COLUMN_NUMBER 138 | > data_len - 1 139 | // end is bigger than last index in the data 140 | { 141 | data_len - 1 142 | } else { 143 | // start index + 14 to get the last index 144 | app.start_card_list_index as usize + DISPLAY_COLUMN_NUMBER * DISPLAY_RAWS_NUMBER - 1 145 | } 146 | } 147 | "manga_ranking" => { 148 | let data_len = app.manga_ranking_data.as_ref().unwrap().data.len(); 149 | if app.start_card_list_index as usize + DISPLAY_RAWS_NUMBER * DISPLAY_COLUMN_NUMBER 150 | > data_len - 1 151 | // end is bigger than last index in the data 152 | { 153 | data_len - 1 154 | } else { 155 | // start index + 14 to get the last index 156 | app.start_card_list_index as usize + DISPLAY_COLUMN_NUMBER * DISPLAY_RAWS_NUMBER - 1 157 | } 158 | } 159 | _ => panic!("Unknown type: {}", typ), 160 | } 161 | } 162 | 163 | pub fn get_end_card_index(app: &App) -> usize { 164 | match app.active_display_block { 165 | ActiveDisplayBlock::SearchResultBlock => match app.search_results.selected_tab { 166 | SelectedSearchTab::Manga => get_end_index(app, "manga"), 167 | SelectedSearchTab::Anime => get_end_index(app, "anime"), 168 | }, 169 | ActiveDisplayBlock::AnimeRanking => get_end_index(app, "anime_ranking"), 170 | ActiveDisplayBlock::MangaRanking => get_end_index(app, "manga_ranking"), 171 | _ => { 172 | // Default case, if no specific block is active 173 | get_end_index(app, "anime") 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/ui/side_menu.rs: -------------------------------------------------------------------------------- 1 | use super::{top_three::draw_top_three, util::get_color}; 2 | use crate::app::{ 3 | ActiveBlock, App, ANIME_OPTIONS, ANIME_OPTIONS_RANGE, GENERAL_OPTIONS, GENERAL_OPTIONS_RANGE, 4 | USER_OPTIONS, USER_OPTIONS_RANGE, 5 | }; 6 | 7 | use ratatui::{ 8 | layout::{Alignment, Constraint, Direction, Flex, Layout, Margin, Rect}, 9 | style::{Modifier, Style}, 10 | text::{Line, Span}, 11 | widgets::{Block, BorderType, Borders, List, ListState}, 12 | Frame, 13 | }; 14 | 15 | pub fn draw_routes(f: &mut Frame, app: &App, layout_chunk: Rect) -> Rect { 16 | let chunks = Layout::default() 17 | .direction(Direction::Horizontal) 18 | .constraints([Constraint::Percentage(18), Constraint::Percentage(82)]) 19 | .split(layout_chunk); 20 | 21 | draw_user_block(f, app, chunks[0]); 22 | 23 | chunks[1] 24 | } 25 | 26 | pub fn draw_anime_routes(f: &mut Frame, app: &App, layout_chunk: Rect) { 27 | let current_block = app.active_block; 28 | let highlight_state = current_block == ActiveBlock::Anime; 29 | 30 | let items: Vec = ANIME_OPTIONS 31 | .iter() 32 | .map(|i| { 33 | Line::from(*i) 34 | .alignment(Alignment::Center) 35 | .style(Style::default().fg(app.app_config.theme.text)) 36 | }) 37 | .collect(); 38 | let block = Block::default() 39 | .borders(Borders::ALL) 40 | .border_type(BorderType::Rounded) 41 | .title(Span::styled( 42 | "Anime", 43 | get_color(highlight_state, app.app_config.theme), 44 | )) 45 | .border_style(get_color(highlight_state, app.app_config.theme)); 46 | 47 | f.render_widget(block, layout_chunk); 48 | 49 | let mut index = Some(app.library.selected_index); 50 | if !ANIME_OPTIONS_RANGE.contains(&app.library.selected_index) { 51 | index = None; 52 | } 53 | let [list_layout] = Layout::default() 54 | .direction(Direction::Vertical) 55 | .constraints([Constraint::Length(3)]) 56 | .flex(Flex::Center) 57 | .areas(layout_chunk); 58 | draw_selectable_list(f, app, list_layout, items, index); 59 | } 60 | 61 | pub fn draw_user_routes(f: &mut Frame, app: &App, layout_chunk: Rect) { 62 | let current_block = app.active_block; 63 | let highlight_state = current_block == ActiveBlock::User; 64 | 65 | let items: Vec = USER_OPTIONS 66 | .iter() 67 | .map(|i| { 68 | Line::from(*i) 69 | .alignment(Alignment::Center) 70 | .style(Style::default().fg(app.app_config.theme.text)) 71 | }) 72 | .collect(); 73 | let block = Block::default() 74 | .borders(Borders::ALL) 75 | .border_type(BorderType::Rounded) 76 | .title(Span::styled( 77 | "User", 78 | get_color(highlight_state, app.app_config.theme), 79 | )) 80 | .border_style(get_color(highlight_state, app.app_config.theme)); 81 | 82 | f.render_widget(block, layout_chunk); 83 | 84 | let mut index = Some(app.library.selected_index); 85 | if !USER_OPTIONS_RANGE.contains(&app.library.selected_index) { 86 | index = None; 87 | } 88 | let [list_layout] = Layout::default() 89 | .direction(Direction::Vertical) 90 | .constraints([Constraint::Length(3)]) 91 | .flex(Flex::Center) 92 | .areas(layout_chunk); 93 | draw_selectable_list(f, app, list_layout, items, index); 94 | } 95 | 96 | pub fn draw_options_routes(f: &mut Frame, app: &App, layout_chunk: Rect) { 97 | let current_block = app.active_block; 98 | let highlight_state = current_block == ActiveBlock::Option; 99 | 100 | let items: Vec = GENERAL_OPTIONS 101 | .iter() 102 | .map(|i| { 103 | Line::from(*i) 104 | .alignment(Alignment::Center) 105 | .style(Style::default().fg(app.app_config.theme.text)) 106 | }) 107 | .collect(); 108 | 109 | let block = Block::default() 110 | .borders(Borders::ALL) 111 | .border_type(BorderType::Rounded) 112 | .title(Span::styled( 113 | "Options", 114 | get_color(highlight_state, app.app_config.theme), 115 | )) 116 | .border_style(get_color(highlight_state, app.app_config.theme)); 117 | 118 | f.render_widget(block, layout_chunk); 119 | 120 | let mut index = Some(app.library.selected_index); 121 | if !GENERAL_OPTIONS_RANGE.contains(&app.library.selected_index) { 122 | index = None; 123 | } 124 | let [list_layout] = Layout::default() 125 | .direction(Direction::Vertical) 126 | .constraints([Constraint::Length(3)]) 127 | .flex(Flex::Center) 128 | .areas(layout_chunk); 129 | draw_selectable_list(f, app, list_layout, items, index); 130 | } 131 | 132 | pub fn draw_user_block(f: &mut Frame, app: &App, layout_chunk: Rect) { 133 | let [anime_routes_chunk, user_routes_chunk, option_route_chunk, top_three_chunk] = 134 | Layout::default() 135 | .direction(Direction::Vertical) 136 | .constraints([ 137 | Constraint::Length(5), 138 | Constraint::Length(5), 139 | Constraint::Length(5), 140 | Constraint::Fill(1), 141 | ]) 142 | .areas(layout_chunk.inner(Margin::new(1, 0))); 143 | 144 | draw_anime_routes(f, app, anime_routes_chunk); 145 | draw_user_routes(f, app, user_routes_chunk); 146 | draw_options_routes(f, app, option_route_chunk); 147 | draw_top_three(f, app, top_three_chunk); 148 | } 149 | 150 | pub fn draw_selectable_list( 151 | f: &mut Frame, 152 | app: &App, 153 | layout_chunk: Rect, 154 | items: Vec, 155 | selected_index: Option, 156 | ) { 157 | let mut state = ListState::default(); 158 | if let Some(index) = selected_index { 159 | state.select(Some(index % items.len())); 160 | } 161 | 162 | // choose color based on hover state 163 | let items = List::new(items).highlight_style( 164 | Style::default() 165 | .fg(app.app_config.theme.selected) 166 | .add_modifier(Modifier::BOLD), 167 | ); 168 | 169 | // let centered_rect = display_block::center_area(layout_chunk, 80, 60); 170 | 171 | f.render_stateful_widget(items, layout_chunk, &mut state); 172 | } 173 | -------------------------------------------------------------------------------- /src/ui/util.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::{Modifier, Style}; 2 | 3 | // use crate::api::model::*; 4 | use crate::app::App; 5 | use crate::config::app_config::Theme; 6 | 7 | pub const SMALL_TERMINAL_HEIGHT: u16 = 45; 8 | 9 | pub fn get_color(is_active: bool, theme: Theme) -> Style { 10 | match is_active { 11 | true => Style::default() 12 | .fg(theme.selected) 13 | .add_modifier(Modifier::BOLD), 14 | _ => Style::default().fg(theme.inactive), 15 | } 16 | } 17 | 18 | pub fn capitalize_each_word(text: String) -> String { 19 | text.split_whitespace() 20 | .map(|word| { 21 | let mut c = word.chars(); 22 | match c.next() { 23 | None => String::new(), 24 | Some(f) => f.to_uppercase().collect::() + c.as_str(), 25 | } 26 | }) 27 | .collect::>() 28 | .join(" ") 29 | } 30 | 31 | pub fn get_main_layout_margin(app: &App) -> u16 { 32 | if app.size.height > SMALL_TERMINAL_HEIGHT { 33 | 1 34 | } else { 35 | 0 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /todolist.txt: -------------------------------------------------------------------------------- 1 | => result: 2 | - click p for next page pagination (feature) 3 | 4 | => keys bar: 5 | - keys bar for each display_block 6 | 7 | => general: 8 | - change demo video to a gif 9 | 10 | => detail page: 11 | - add a key for deleting the list of the anime (delete endpoint) (feature) 12 | 13 | ==> args: 14 | - add for info (like files path, etc) (feature) 15 | - add help for intro and what to do 16 | - add args as seperate file (feature) --------------------------------------------------------------------------------