From 20f98b422dfde3e158215079df880960cdae0418 Mon Sep 17 00:00:00 2001 From: Brad Culwell Date: Wed, 9 Oct 2024 01:16:38 -0500 Subject: [PATCH] refactor: rewrite v0.10 initial --- .default-config/keybinds.toml | 10 + Cargo.lock | 211 ++++- Cargo.toml | 42 +- src/action.rs | 32 + src/app.rs | 759 +++------------- src/cli.rs | 54 ++ src/client.rs | 224 ----- src/client/cmd.rs | 72 -- src/client/default_app.rs | 57 -- src/client/download.rs | 141 --- src/client/qbit.rs | 257 ------ src/client/rqbit.rs | 118 --- src/client/transmission.rs | 145 --- tests/config/config.toml => src/clients.rs | 0 src/clients/qBittorrent.rs | 0 src/clip.rs | 168 ---- src/clip/bin.rs | 1 - src/components.rs | 14 + src/components/actions_temp.rs | 39 + src/components/batch.rs | 0 src/components/home.rs | 42 + src/components/results.rs | 38 + src/components/search.rs | 0 src/config.rs | 227 +---- src/errors.rs | 0 src/keys.rs | 292 ++++++ src/lib.rs | 11 - src/macros.rs | 187 ---- src/main.rs | 94 +- src/results.rs | 213 ----- src/source.rs | 381 -------- src/source/nyaa_html.rs | 584 ------------ src/source/nyaa_rss.rs | 155 ---- src/source/sukebei_nyaa.rs | 395 -------- src/source/torrent_galaxy.rs | 852 ------------------ src/sources.rs | 0 src/sources/nyaa.rs | 0 src/sync.rs | 177 ---- src/theme.rs | 208 ----- src/themes.rs | 1 + src/tui.rs | 205 +++++ src/util.rs | 7 - src/util/cmd.rs | 64 -- src/util/colors.rs | 111 --- src/util/conv.rs | 173 ---- src/util/html.rs | 26 - src/util/strings.rs | 152 ---- src/util/term.rs | 59 -- src/util/types.rs | 24 - src/widget.rs | 291 ------ src/widget/batch.rs | 172 ---- src/widget/captcha.rs | 96 -- src/widget/category.rs | 237 ----- src/widget/clients.rs | 99 -- src/widget/filter.rs | 96 -- src/widget/help.rs | 134 --- src/widget/input.rs | 180 ---- src/widget/notifications.rs | 165 ---- src/widget/notify_box.rs | 328 ------- src/widget/page.rs | 98 -- src/widget/results.rs | 452 ---------- src/widget/search.rs | 88 -- src/widget/sort.rs | 151 ---- src/widget/sources.rs | 104 --- src/widget/themes.rs | 160 ---- src/widget/user.rs | 86 -- {tests => tests-old}/common/mod.rs | 0 tests-old/config/config.toml | 0 .../config/themes/dracula2.toml | 0 {tests => tests-old}/input.rs | 0 {tests => tests-old}/popups.rs | 0 {tests => tests-old}/query.rs | 0 72 files changed, 1083 insertions(+), 8876 deletions(-) create mode 100644 .default-config/keybinds.toml create mode 100644 src/action.rs create mode 100644 src/cli.rs delete mode 100644 src/client.rs delete mode 100644 src/client/cmd.rs delete mode 100644 src/client/default_app.rs delete mode 100644 src/client/download.rs delete mode 100644 src/client/qbit.rs delete mode 100644 src/client/rqbit.rs delete mode 100644 src/client/transmission.rs rename tests/config/config.toml => src/clients.rs (100%) create mode 100644 src/clients/qBittorrent.rs delete mode 100644 src/clip.rs delete mode 100644 src/clip/bin.rs create mode 100644 src/components.rs create mode 100644 src/components/actions_temp.rs create mode 100644 src/components/batch.rs create mode 100644 src/components/home.rs create mode 100644 src/components/results.rs create mode 100644 src/components/search.rs create mode 100644 src/errors.rs create mode 100644 src/keys.rs delete mode 100644 src/lib.rs delete mode 100644 src/macros.rs delete mode 100644 src/results.rs delete mode 100644 src/source.rs delete mode 100644 src/source/nyaa_html.rs delete mode 100644 src/source/nyaa_rss.rs delete mode 100644 src/source/sukebei_nyaa.rs delete mode 100644 src/source/torrent_galaxy.rs create mode 100644 src/sources.rs create mode 100644 src/sources/nyaa.rs delete mode 100644 src/sync.rs delete mode 100644 src/theme.rs create mode 100644 src/themes.rs create mode 100644 src/tui.rs delete mode 100644 src/util.rs delete mode 100644 src/util/cmd.rs delete mode 100644 src/util/colors.rs delete mode 100644 src/util/conv.rs delete mode 100644 src/util/html.rs delete mode 100644 src/util/strings.rs delete mode 100644 src/util/term.rs delete mode 100644 src/util/types.rs delete mode 100644 src/widget.rs delete mode 100644 src/widget/batch.rs delete mode 100644 src/widget/captcha.rs delete mode 100644 src/widget/category.rs delete mode 100644 src/widget/clients.rs delete mode 100644 src/widget/filter.rs delete mode 100644 src/widget/help.rs delete mode 100644 src/widget/input.rs delete mode 100644 src/widget/notifications.rs delete mode 100644 src/widget/notify_box.rs delete mode 100644 src/widget/page.rs delete mode 100644 src/widget/results.rs delete mode 100644 src/widget/search.rs delete mode 100644 src/widget/sort.rs delete mode 100644 src/widget/sources.rs delete mode 100644 src/widget/themes.rs delete mode 100644 src/widget/user.rs rename {tests => tests-old}/common/mod.rs (100%) create mode 100644 tests-old/config/config.toml rename {tests => tests-old}/config/themes/dracula2.toml (100%) rename {tests => tests-old}/input.rs (100%) rename {tests => tests-old}/popups.rs (100%) rename {tests => tests-old}/query.rs (100%) diff --git a/.default-config/keybinds.toml b/.default-config/keybinds.toml new file mode 100644 index 0000000..e3e277e --- /dev/null +++ b/.default-config/keybinds.toml @@ -0,0 +1,10 @@ +[Home] +"q" = "Quit" +"" = "Suspend" +"" = "SetMode Test" +"t" = { "SetMode" = "Test" } + +[Test] +"t" = "SetMode Home" +"q" = "SetMode Home" +"esc" = "SetMode Home" diff --git a/Cargo.lock b/Cargo.lock index 4e50ab4..8601cea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "addr2line" -version = "0.22.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" dependencies = [ "gimli", ] @@ -87,9 +87,9 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "backtrace" -version = "0.3.73" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" dependencies = [ "addr2line", "cc", @@ -178,12 +178,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chrono" version = "0.4.38" @@ -208,6 +202,33 @@ dependencies = [ "error-code", ] +[[package]] +name = "color-eyre" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55146f5e46f237f7423d74111267d4597b59b0dad0ffaf7303bce9945d843ad5" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "compact_str" version = "0.8.0" @@ -273,6 +294,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.5.0", "crossterm_winapi", + "futures-core", "mio 1.0.2", "parking_lot", "rustix", @@ -322,6 +344,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_deref" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcdbcee2d9941369faba772587a565f4f534e42cb8d17e5295871de730163b2b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "0.99.18" @@ -458,6 +491,16 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b" +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fdeflate" version = "0.3.4" @@ -502,6 +545,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.30" @@ -509,6 +567,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -517,6 +576,34 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + [[package]] name = "futures-sink" version = "0.3.30" @@ -535,10 +622,16 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -573,9 +666,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.29.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "hashbrown" @@ -909,6 +1002,12 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "indenter" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" + [[package]] name = "indexmap" version = "2.2.6" @@ -917,6 +1016,7 @@ checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", "hashbrown", + "serde", ] [[package]] @@ -987,6 +1087,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "lexopt" version = "0.3.0" @@ -1129,18 +1235,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.5.0", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "num-conv" version = "0.1.0" @@ -1179,14 +1273,16 @@ dependencies = [ "arboard", "base64", "chrono", + "color-eyre", "crossterm", + "derive_deref", "directories", "dirs", + "futures", "human_bytes", "image", "indexmap", "lexopt", - "nix", "open", "ratatui", "ratatui-image", @@ -1195,10 +1291,14 @@ dependencies = [ "scraper", "serde", "shellexpand", + "signal-hook", "strum", "textwrap", "tokio", + "tokio-util", "toml", + "tracing", + "tracing-error", "transmission-rpc", "unicode-width", "urlencoding", @@ -1305,9 +1405,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.0" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "576dfe1fc8f9df304abb159d767a29d0476f7750fbf8aa7ad07816004a207434" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" dependencies = [ "memchr", ] @@ -1335,6 +1435,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + [[package]] name = "parking_lot" version = "0.12.3" @@ -1971,6 +2077,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shellexpand" version = "3.1.0" @@ -2207,6 +2322,16 @@ dependencies = [ "syn 2.0.66", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.3.36" @@ -2304,9 +2429,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" dependencies = [ "bytes", "futures-core", @@ -2405,6 +2530,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", ] [[package]] @@ -2511,6 +2658,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "version_check" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index f1c2a75..e9a2187 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ readme = "README.md" repository = "https://github.com/Beastwick18/nyaa/" license = "GPL-3.0-or-later" +exclude = ["/.github", "/assets", "/docs", "/examples", "/modules", "/scripts"] + [profile.release] debug = 0 lto = false @@ -22,12 +24,19 @@ strip = "none" lto = true [dependencies] -reqwest = { version = "0.12.5", features = ["cookies", "gzip", "json"], default-features = false } +reqwest = { version = "0.12.5", features = [ + "cookies", + "gzip", + "json", +], default-features = false } tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] } +tokio-util = "0.7.12" urlencoding = "2.1.3" -ratatui = { version = "0.28.0", default-features = false, features = ["crossterm"] } +ratatui = { version = "0.28.0", default-features = false, features = [ + "crossterm", +] } textwrap = { version = "0.16.1", default-features = false } -crossterm = { version = "0.28.1", default-features = false } +crossterm = { version = "0.28.1", features = ["event-stream"] } unicode-width = "0.1.13" toml = "0.8.14" directories = "5.0.1" @@ -39,17 +48,24 @@ transmission-rpc = { version = "0.4.3" } open = "5.1.4" dirs = "5.0.1" shellexpand = "3.1.0" -indexmap = { version = "2.2.6", default-features = false } +indexmap = { version = "2.2.6", default-features = false, features = ["serde"] } human_bytes = { version = "0.4.3", default-features = false } strum = { version = "0.26.2", default-features = false } base64 = { version = "0.22.1", default-features = false, features = ["alloc"] } lexopt = "0.3.0" -ratatui-image = { version = "1.0.5", optional = true , default-features = false } -image = { version = "0.25.1", optional = true, features = ["png"], default-features = false } +ratatui-image = { version = "1.0.5", optional = true, default-features = false } +image = { version = "0.25.1", optional = true, features = [ + "png", +], default-features = false } +color-eyre = "0.6.3" +futures = "0.3.30" +tracing = "0.1.40" +tracing-error = "0.2.0" +derive_deref = "1.1.1" -[lib] -name = "nyaa" -path = "src/lib.rs" +# [lib] +# name = "nyaa" +# path = "src/lib.rs" [features] captcha = ["dep:ratatui-image", "dep:image"] @@ -57,14 +73,16 @@ captcha = ["dep:ratatui-image", "dep:image"] [cargo-features-manager.keep] image = ["png"] -[target.'cfg(unix)'.dependencies] -nix = { version = "0.29.0", features = ["signal"] } +[target.'cfg(not(windows))'.dependencies] +signal-hook = "0.3.17" [target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))'.dependencies] arboard = { version = "3.4", default-features = false } [target.'cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))'.dependencies] -ratatui = { version = "0.28.0", default-features = false, features = ["termion"] } +ratatui = { version = "0.28.0", default-features = false, features = [ + "termion", +] } [package.metadata.deb] maintainer = "Steven Culwell " diff --git a/src/action.rs b/src/action.rs new file mode 100644 index 0000000..37cf710 --- /dev/null +++ b/src/action.rs @@ -0,0 +1,32 @@ +use serde::Deserialize; +use strum::Display; + +use crate::app::Mode; + +// Actions generated by the app +#[derive(Display, Debug, Clone, PartialEq, Eq)] +pub enum AppAction { + Tick, + Render, + Resize(u16, u16), + // Suspend, // TODO: Maybe keep, maybe replace with UserAction::Suspend + Resume, + // Quit, // TODO: Maybe keep, maybe replace with UserAction::Quit + ClearScreen, + Error(String), + UserAction(UserAction), +} + +// Actions generated by the user +#[derive(Deserialize, Display, Debug, Clone, PartialEq, Eq)] +pub enum UserAction { + Suspend, + Quit, + Up, + Down, + InsertMoveLeft, + InsertMoveRight, + InsertNextWord, + InsertPrevWord, + SetMode(Mode), +} diff --git a/src/app.rs b/src/app.rs index 03295d0..b7203ab 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,681 +1,154 @@ -use std::{ - error::Error, - fmt::Display, - sync::Arc, - time::{Duration, Instant}, -}; - -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}; -use indexmap::IndexMap; -use ratatui::{ - backend::Backend, - layout::{Constraint, Direction, Layout, Position}, - Frame, Terminal, -}; -use reqwest::cookie::Jar; -use tokio::{sync::mpsc, task::AbortHandle}; - -#[cfg(feature = "captcha")] -use crate::widget::captcha::CaptchaPopup; +use color_eyre::Result; +use crossterm::event::KeyEvent; +use serde::Deserialize; +use strum::Display; +use tokio::sync::mpsc; +use tracing::debug; use crate::{ - client::{Client, DownloadClientResult, SingleDownloadResult}, - clip::ClipboardManager, - config::{Config, ConfigManager}, - results::Results, - source::{ - nyaa_html::NyaaHtmlSource, request_client, Item, Source, SourceInfo, SourceResults, Sources, - }, - sync::{EventSync, ReloadType, SearchQuery}, - theme::{self, Theme}, - util::conv::key_to_string, - widget::{ - batch::BatchWidget, - category::CategoryPopup, - clients::ClientsPopup, - filter::FilterPopup, - help::HelpPopup, - notifications::{Notification, NotificationWidget}, - page::PagePopup, - results::ResultsWidget, - search::SearchWidget, - sort::{SortDir, SortPopup}, - sources::SourcesPopup, - themes::ThemePopup, - user::UserPopup, - Widget, - }, - widgets, + action::{AppAction, UserAction}, + cli::Args, + components::{home::HomeComponent, Component}, + config::Config, + tui::{Tui, TuiEvent}, }; -#[cfg(unix)] -use core::panic; -#[cfg(unix)] -use crossterm::event::KeyModifiers; - -#[cfg(unix)] -use crate::util::term; - -pub static APP_NAME: &str = "nyaa"; - -// To ensure that other events will get a chance to be received -static ANIMATE_SLEEP_MILLIS: u64 = 5; - -#[derive(PartialEq, Clone)] -pub enum LoadType { - Sourcing, - Searching, - SolvingCaptcha(String), - Sorting, - Filtering, - Categorizing, - Batching, - Downloading, -} - -#[derive(PartialEq, Clone)] +#[derive(Deserialize, Default, Display, Debug, Hash, PartialEq, Eq, Copy, Clone)] pub enum Mode { - Normal, - Loading(LoadType), - KeyCombo(String), - Search, - Category, - Sort(SortDir), - Batch, - Filter, - Theme, - Sources, - Clients, - Page, - User, - Help, - #[cfg(feature = "captcha")] - Captcha, + #[default] + Home, + Test, // TODO: Remove } -widgets! { - Widgets; - batch: [Mode::Batch] => BatchWidget, - search: [Mode::Search] => SearchWidget, - results: [Mode::Normal] => ResultsWidget, - notification: NotificationWidget, - [popups]: { - category: [Mode::Category] => CategoryPopup, - sort: [Mode::Sort(_)] => SortPopup, - filter: [Mode::Filter] => FilterPopup, - theme: [Mode::Theme] => ThemePopup, - sources: [Mode::Sources] => SourcesPopup, - clients: [Mode::Clients] => ClientsPopup, - page: [Mode::Page] => PagePopup, - user: [Mode::User] => UserPopup, - help: [Mode::Help] => HelpPopup, - #[cfg(feature = "captcha")] - captcha: [Mode::Captcha] => CaptchaPopup, - } -} - -impl Display for LoadType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match self { - LoadType::Sourcing => "Sourcing", - LoadType::Searching => "Searching", - LoadType::SolvingCaptcha(_) => "Solving", - LoadType::Sorting => "Sorting", - LoadType::Filtering => "Filtering", - LoadType::Categorizing => "Categorizing", - LoadType::Batching => "Downloading Batch", - LoadType::Downloading => "Downloading", - }; - write!(f, "{}", s) - } -} - -impl Display for Mode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match self { - Mode::Normal | Mode::KeyCombo(_) => "Normal", - Mode::Batch => "Batch", - Mode::Search => "Search", - Mode::Category => "Category", - Mode::Sort(_) => "Sort", - Mode::Filter => "Filter", - Mode::Theme => "Theme", - Mode::Sources => "Sources", - Mode::Clients => "Clients", - Mode::Loading(_) => "Loading", - Mode::Page => "Page", - Mode::User => "User", - Mode::Help => "Help", - #[cfg(feature = "captcha")] - Mode::Captcha => "Captcha", - } - .to_owned(); - write!(f, "{}", s) - } -} - -#[derive(Default)] pub struct App { - pub widgets: Widgets, -} - -#[derive(Clone)] -pub struct Context { - pub mode: Mode, - pub load_type: Option, - pub themes: IndexMap, - pub src_info: SourceInfo, - pub theme: Theme, - pub config: Config, - pub page: usize, - pub user: Option, - pub src: Sources, - pub client: Client, - pub batch: Vec, - pub last_key: String, - pub results: Results, - pub deltatime: f64, - //errors: Vec, - notifications: Vec, - failed_config_load: bool, + config: Config, + mode: Mode, should_quit: bool, - should_dismiss_notifications: bool, - should_save_config: bool, - skip_reload: bool, + should_suspend: bool, + action_tx: mpsc::UnboundedSender, + action_rx: mpsc::UnboundedReceiver, + components: Vec>, } -impl Context { - pub fn notify_error(&mut self, msg: S) { - self.notify(Notification::error(msg)); - } - - pub fn notify_info(&mut self, msg: S) { - self.notify(Notification::info(msg)); - } - - pub fn notify_warn(&mut self, msg: S) { - self.notify(Notification::warning(msg)); - } - - pub fn notify_success(&mut self, msg: S) { - self.notify(Notification::success(msg)); - } - - pub fn notify(&mut self, notif: Notification) { - self.notifications.push(notif); - } +impl App { + pub fn new(_args: Args) -> Result { + let (action_tx, action_rx) = mpsc::unbounded_channel(); + Ok(Self { + config: Config::new()?, + mode: Mode::default(), + should_quit: false, + should_suspend: false, + action_tx, + action_rx, + components: vec![Box::new(HomeComponent::new())], + }) + } + + pub async fn run(&mut self) -> Result<()> { + let mut tui = Tui::new()? + .tick_rate(4.0) // TODO: Eliminate or gather from config + .frame_rate(60.0); // TODO: Eliminate or gather from config + tui.enter()?; + + // Initialize components + + let action_tx = self.action_tx.clone(); + loop { + self.handle_events(&mut tui).await?; + self.handle_actions(&mut tui)?; + + if self.should_suspend { + tui.suspend()?; + action_tx.send(AppAction::Resume)?; + action_tx.send(AppAction::ClearScreen)?; + tui.enter()?; + } else if self.should_quit { + tui.stop()?; + break; + } + } - pub fn dismiss_notifications(&mut self) { - self.should_dismiss_notifications = true; - } + tui.exit()?; - pub fn save_config(&mut self) -> Result<(), Box> { - self.should_save_config = true; - self.skip_reload = true; Ok(()) } - pub fn quit(&mut self) { - self.should_quit = true; - } -} + async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> { + let Some(event) = tui.next_event().await else { + return Ok(()); + }; -impl Default for Context { - fn default() -> Self { - Context { - mode: Mode::Loading(LoadType::Searching), - load_type: None, - themes: theme::default_themes(), - src_info: NyaaHtmlSource::info(), - theme: Theme::default(), - config: Config::default(), - notifications: Vec::new(), - page: 1, - user: None, - src: Sources::Nyaa, - client: Client::Cmd, - batch: vec![], - last_key: "".to_owned(), - results: Results::default(), - deltatime: 0.0, - failed_config_load: true, - should_quit: false, - should_dismiss_notifications: false, - should_save_config: false, - skip_reload: false, - } + let action_tx = self.action_tx.clone(); + match event { + // TuiEvent::Quit => action_tx.send(AppAction::UserAction(UserAction::Quit))?, + TuiEvent::Tick => action_tx.send(AppAction::Tick)?, + TuiEvent::Render => action_tx.send(AppAction::Render)?, + TuiEvent::Resize(x, y) => action_tx.send(AppAction::Resize(x, y))?, + TuiEvent::Key(key) => self.handle_key_event(key)?, + _ => {} + }; + // for component in self.components.iter_mut() { + // if let Some(action) = components.handle_events(Some(event.clone()))? { + // action_tx.send(action)?; + // } + // } + Ok(()) } -} - -impl App { - pub async fn run_app( - &mut self, - terminal: &mut Terminal, - sync: S, - config_manager: C, - ) -> Result<(), Box> { - let ctx = &mut Context::default(); - let timer = tokio::time::sleep(Duration::from_millis(ANIMATE_SLEEP_MILLIS)); - tokio::pin!(timer); + fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { + let action_tx = self.action_tx.clone(); - let (tx_res, mut rx_res) = - mpsc::channel::>>(32); - let (tx_evt, mut rx_evt) = mpsc::channel::(100); - let (tx_dl, mut rx_dl) = mpsc::channel::(100); - let (tx_cfg, mut rx_cfg) = mpsc::channel::(1); - - tokio::task::spawn(sync.clone().read_event_loop(tx_evt)); - tokio::task::spawn(sync.clone().watch_config_loop(tx_cfg)); - - match config_manager.load() { - Ok(config) => { - ctx.failed_config_load = false; - if let Err(e) = config.full_apply(config_manager.path(), ctx, &mut self.widgets) { - ctx.notify_error(e); - } - } - Err(e) => { - ctx.notify_error(format!("Failed to load config:\n{}", e)); - if let Err(e) = - ctx.config - .clone() - .full_apply(config_manager.path(), ctx, &mut self.widgets) - { - ctx.notify_error(e); - } - } - } - - let jar = Arc::new(Jar::default()); - let source_rqclient = - request_client(&jar, ctx.config.timeout, ctx.config.request_proxy.clone())?; - // Don't use proxy for clients - let client_rqclient = request_client(&jar, ctx.config.timeout, None)?; - let mut last_load_abort: Option = None; - let mut last_time: Option = None; - - let (clipboard, err) = &mut if TEST { - ClipboardManager::empty(ctx.config.clipboard.clone().unwrap_or_default()) - } else { - ClipboardManager::new(ctx.config.clipboard.clone().unwrap_or_default()) + let Some(keymap) = self.config.keys.get(&self.mode) else { + return Ok(()); }; - if let Some(err) = err { - ctx.notify_error(err); - } - - while !ctx.should_quit { - if ctx.should_save_config && ctx.config.save_config_on_change { - if let Err(e) = config_manager.store(&ctx.config) { - ctx.notify_error(e); - } - ctx.should_save_config = false; - } - if !ctx.notifications.is_empty() { - ctx.notifications - .clone() - .into_iter() - .for_each(|n| self.widgets.notification.add(n)); - ctx.notifications.clear(); - } - //if !ctx.errors.is_empty() { - // if TEST { - // return Err(ctx.errors.join("\n\n").into()); - // } - // ctx.errors - // .clone() - // .into_iter() - // .for_each(|n| self.widgets.notification.add(Notification::Error, n)); - // ctx.errors.clear(); - //} - if ctx.should_dismiss_notifications { - self.widgets.notification.dismiss_all(); - ctx.should_dismiss_notifications = false; - } - if ctx.mode == Mode::Batch && ctx.batch.is_empty() { - ctx.mode = Mode::Normal; - } - - self.get_help(ctx); - terminal.draw(|f| self.draw(ctx, f))?; - if let Mode::Loading(load_type) = ctx.mode.clone() { - ctx.mode = Mode::Normal; - match load_type { - LoadType::Downloading => { - if let Some(i) = self - .widgets - .results - .table - .selected() - .and_then(|i| ctx.results.response.items.get(i)) - { - tokio::spawn(sync.clone().download( - tx_dl.clone(), - false, - vec![i.to_owned()], - ctx.config.client.clone(), - client_rqclient.clone(), - ctx.client, - )); - ctx.notify_info(format!("Downloading torrent with {}", ctx.client)); - } - continue; - } - LoadType::Batching => { - tokio::spawn(sync.clone().download( - tx_dl.clone(), - true, - ctx.batch.clone(), - ctx.config.client.clone(), - client_rqclient.clone(), - ctx.client, - )); - ctx.notify_info(format!( - "Downloading {} torrents with {}", - ctx.batch.len(), - ctx.client - )); - continue; - } - LoadType::Sourcing => { - // On sourcing, update info, reset things like category, etc. - ctx.src.apply(ctx, &mut self.widgets); - } - _ => {} - } - - ctx.load_type = Some(load_type.clone()); - - if let Some(handle) = last_load_abort.as_ref() { - handle.abort(); - } - - let search = SearchQuery { - query: self.widgets.search.input.input.clone(), - page: ctx.page, - category: self.widgets.category.selected, - filter: self.widgets.filter.selected, - sort: self.widgets.sort.selected, - user: ctx.user.clone(), - }; - - let task = tokio::spawn(sync.clone().load_results( - tx_res.clone(), - load_type.clone(), - ctx.src, - source_rqclient.clone(), - search, - ctx.config.sources.clone(), - ctx.theme.clone(), - ctx.config.clone().into(), - )); - last_load_abort = Some(task.abort_handle()); - continue; // Redraw + match keymap.get(&vec![key]) { + Some(action) => { + action_tx.send(AppAction::UserAction(action.clone()))?; } - - loop { - tokio::select! { - biased; - Some(evt) = rx_evt.recv() => { - #[cfg(unix)] - self.on::(&evt, ctx, clipboard, terminal); - #[cfg(not(unix))] - self.on::(&evt, ctx, clipboard); - - break; - }, - () = &mut timer, if self.widgets.notification.is_animating() => { - timer.as_mut().reset(tokio::time::Instant::now() + Duration::from_millis(ANIMATE_SLEEP_MILLIS)); - if let Ok(size) = terminal.size() { - let now = Instant::now(); - ctx.deltatime = last_time.map(|l| (now - l).as_secs_f64()).unwrap_or(0.0); - last_time = Some(now); - - if self.widgets.notification.update(ctx.deltatime, (Position::ORIGIN, size).into()) { - break; - } - } else { - break; - } - }, - Some(rt) = rx_res.recv() => { - match rt { - Ok(SourceResults::Results(rt)) => { - self.widgets.results.reset(); - ctx.results = rt; - } - #[cfg(feature = "captcha")] - Ok(SourceResults::Captcha(c)) => { - ctx.results = Results::default(); - ctx.mode = Mode::Captcha; - self.widgets.captcha.image = Some(c); - self.widgets.captcha.input.clear(); - } - Err(e) => { - // Clear results on error - ctx.results = Results::default(); - ctx.notify_error(e); - }, - } - ctx.load_type = None; - last_load_abort = None; - break; - }, - Some(dl) = rx_dl.recv() => { - match dl { - DownloadClientResult::Single(sr) => { - match sr { - SingleDownloadResult::Success(suc) => { - ctx.notify(suc.msg); - }, - SingleDownloadResult::Error(err) => { - ctx.notify(err.msg); - }, - }; - } - DownloadClientResult::Batch(br) => { - if !br.ids.is_empty() { - ctx.notify(br.msg); - } - br.errors.into_iter().for_each(|e| ctx.notify(e)); - } - } - break; - } - Some(notif) = rx_cfg.recv() => { - if ctx.skip_reload { - ctx.skip_reload = false; - continue; - } - match notif { - ReloadType::Config => { - match config_manager.load() { - Ok(config) => { - match config.partial_apply(ctx, &mut self.widgets) { - Ok(()) => ctx.notify_info("Reloaded config".to_owned()), - Err(e) => ctx.notify_error(e), - } - } - Err(e) => ctx.notify_error(e), - } - }, - ReloadType::Theme(t) => match theme::load_user_themes(ctx, config_manager.path()) { - Ok(()) => ctx.notify_info(format!("Reloaded theme \"{t}\"")), - Err(e) => ctx.notify_error(e) - }, - } - - break; - }, - else => { - return Err("All channels closed".into()); - } - }; - } - if !self.widgets.notification.is_animating() { - last_time = None; + _ => { + // TODO: Handle multikey } } Ok(()) } - pub fn draw(&mut self, ctx: &mut Context, f: &mut Frame) { - let layout_vertical = Layout::new( - Direction::Vertical, - [Constraint::Length(3), Constraint::Min(1)], - ) - .split(f.area()); - - self.widgets.search.draw(f, ctx, layout_vertical[0]); - // Dont draw batch pane if empty - if ctx.batch.is_empty() { - self.widgets.results.draw(f, ctx, layout_vertical[1]); - } else { - let layout_horizontal = Layout::new( - Direction::Horizontal, - match ctx.mode { - Mode::Batch | Mode::Help => [Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)], - _ => [Constraint::Ratio(3, 4), Constraint::Ratio(1, 4)], + fn handle_actions(&mut self, tui: &mut Tui) -> Result<()> { + while let Ok(action) = self.action_rx.try_recv() { + if action != AppAction::Tick && action != AppAction::Render { + // Special action + debug!("{action:?}"); + } + match &action { + AppAction::UserAction(u) => match u { + UserAction::Quit => self.should_quit = true, + UserAction::Suspend => self.should_suspend = true, + UserAction::SetMode(m) => self.mode = *m, + _ => {} }, - ) - .split(layout_vertical[1]); - self.widgets.results.draw(f, ctx, layout_horizontal[0]); - self.widgets.batch.draw(f, ctx, layout_horizontal[1]); - } - self.widgets.draw_popups(ctx, f); - self.widgets.notification.draw(f, ctx, f.area()); - } - - fn on( - &mut self, - evt: &Event, - ctx: &mut Context, - clipboard: &mut ClipboardManager, - #[cfg(unix)] terminal: &mut Terminal, - ) { - if TEST && Event::FocusLost == *evt { - ctx.quit(); - return; - } - - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - modifiers, - .. - }) = evt - { - #[cfg(unix)] - if let (KeyCode::Char('z'), &KeyModifiers::CONTROL) = (code, modifiers) { - if let Err(e) = term::suspend_self(terminal) { - ctx.notify_error(format!("Failed to suspend:\n{}", e)); - } - // If we fail to continue the process, panic - if let Err(e) = term::continue_self(terminal) { - panic!("Failed to continue program:\n{}", e); - } - return; + AppAction::Resume => self.should_suspend = false, + AppAction::Render => self.render(tui)?, + AppAction::ClearScreen => tui.clear()?, + _ => {} } - match ctx.mode.to_owned() { - Mode::KeyCombo(keys) => { - ctx.last_key = keys; - } - _ => ctx.last_key = key_to_string(*code, *modifiers), - }; - } - match ctx.mode.to_owned() { - Mode::KeyCombo(keys) => self.on_combo(ctx, clipboard, keys, evt), - Mode::Loading(_) => {} - _ => self.widgets.handle_event(ctx, evt), - } - if ctx.mode != Mode::Help { - self.on_help(evt, ctx); - } - } - - fn on_help(&mut self, e: &Event, ctx: &mut Context) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - .. - }) = e - { - match code { - KeyCode::Char('?') if ctx.mode != Mode::Search => { - ctx.mode = Mode::Help; - } - KeyCode::F(1) => { - ctx.mode = Mode::Help; + for component in self.components.iter_mut() { + if let Some(action) = component.update(&action)? { + self.action_tx.send(action)?; } - _ => {} } } + Ok(()) } - fn get_help(&mut self, ctx: &Context) { - let help = self.widgets.get_help(&ctx.mode); - if let Some(msg) = help { - self.widgets.help.with_items(msg, ctx.mode.clone()); - self.widgets.help.table.select(0); - } - } - - fn on_combo( - &mut self, - ctx: &mut Context, - clipboard: &mut ClipboardManager, - mut keys: String, - e: &Event, - ) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - .. - }) = e - { - match code { - // Only handle standard chars for now - KeyCode::Char(c) => keys.push(*c), - KeyCode::Esc => { - // Stop combo if esc - ctx.mode = Mode::Normal; - return; - } - _ => {} - } - } - ctx.last_key.clone_from(&keys); - match keys.chars().collect::>()[..] { - ['y', c] => { - let s = self.widgets.results.table.state.selected().unwrap_or(0); - ctx.mode = Mode::Normal; - match ctx.results.response.items.get(s).cloned() { - Some(item) => { - let link = match c { - 't' => item.torrent_link, - 'm' => item.magnet_link, - 'p' => item.post_link, - 'i' => match item.extra.get("imdb").cloned() { - Some(imdb) => imdb, - None => return ctx.notify_error("No imdb ID found for this item."), - }, - 'n' => item.title, - _ => return, - }; - match clipboard.try_copy(&link) { - Ok(()) => { - ctx.notify_success(format!("Copied \"{}\" to clipboard", link)) - } - Err(e) => ctx.notify_error(e), - } - } - None if ['t', 'm', 'p', 'i', 'n'].contains(&c) => { - ctx.notify_error("Failed to copy:\nFailed to get item") - } - None => {} + fn render(&mut self, tui: &mut Tui) -> Result<()> { + tui.draw(|frame| { + for component in self.components.iter_mut() { + if let Err(err) = component.render(frame, frame.area()) { + let _ = self + .action_tx + .send(AppAction::Error(format!("Failed to draw: {:?}", err))); } } - _ => ctx.mode = Mode::KeyCombo(keys), - } + })?; + Ok(()) } } diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..55a4eb6 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,54 @@ +use std::error::Error; + +#[derive(Default)] +pub struct Args { + pub config_path: Option, + pub debug_info: Option, +} + +static HELP_MSG: &str = "\ +A TUI for browsing and downloading torrents + +Usage: + nyaa --config= + nyaa --help + nyaa --version + +Options: +-h --help Show this screen +-v -V --version Show version +-c --config Set the directory to look for config files [default: \"~/.config/nyaa\"]"; + +pub fn read_args() -> Result> { + use lexopt::prelude::*; + + let mut args = Args::default(); + let mut parser = lexopt::Parser::from_env(); + while let Some(arg) = parser.next()? { + match arg { + Short('c') | Long("config") => { + let parser_value = parser.value().expect("Failed to parse cli value"); + + let string_value = parser_value + .string() + .expect("Parsed value was not a valid string"); + + let expanded_value = shellexpand::full(&string_value) + .expect("Failed to expand values within string parsed string"); + + args.config_path = Some(expanded_value.to_string()); + } + Short('v') | Short('V') | Long("version") => { + println!("nyaa v{}", env!("CARGO_PKG_VERSION")); + std::process::exit(0); + } + Short('h') | Long("help") => { + println!("{}", HELP_MSG); + std::process::exit(0); + } + _ => return Err(arg.unexpected().into()), + } + } + + Ok(args) +} diff --git a/src/client.rs b/src/client.rs deleted file mode 100644 index 6d96ed5..0000000 --- a/src/client.rs +++ /dev/null @@ -1,224 +0,0 @@ -use std::fmt::Display; - -use serde::{Deserialize, Serialize}; -use strum::{Display, VariantArray}; -use tokio::task::JoinSet; - -use crate::{client::cmd::CmdClient, source::Item, widget::notifications::Notification}; - -use self::{ - cmd::CmdConfig, - default_app::{DefaultAppClient, DefaultAppConfig}, - download::{DownloadConfig, DownloadFileClient}, - qbit::{QbitClient, QbitConfig}, - rqbit::{RqbitClient, RqbitConfig}, - transmission::{TransmissionClient, TransmissionConfig}, -}; - -pub mod cmd; -pub mod default_app; -pub mod download; -pub mod qbit; -pub mod rqbit; -pub mod transmission; - -pub struct DownloadError(String); - -impl From for DownloadError { - fn from(value: String) -> Self { - Self(value) - } -} - -impl From<&str> for DownloadError { - fn from(value: &str) -> Self { - Self(value.to_owned()) - } -} - -pub trait DownloadClient { - fn download( - item: Item, - conf: ClientConfig, - client: reqwest::Client, - ) -> impl std::future::Future + std::marker::Send + 'static; - fn batch_download( - items: Vec, - conf: ClientConfig, - client: reqwest::Client, - ) -> impl std::future::Future + std::marker::Send + 'static; - fn load_config(cfg: &mut ClientConfig); -} - -impl Display for DownloadError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -pub struct DownloadSuccessResult { - pub msg: Notification, - pub id: String, -} - -pub struct DownloadErrorResult { - pub msg: Notification, -} - -pub enum SingleDownloadResult { - Success(DownloadSuccessResult), - Error(DownloadErrorResult), -} - -pub struct BatchDownloadResult { - pub msg: Notification, - pub errors: Vec, - pub ids: Vec, -} - -pub enum DownloadClientResult { - Single(SingleDownloadResult), - Batch(BatchDownloadResult), -} - -impl SingleDownloadResult { - pub fn success(msg: S, id: String) -> Self { - Self::Success(DownloadSuccessResult { - msg: Notification::success(msg), - id, - }) - } - - pub fn error(msg: S) -> Self { - Self::Error(DownloadErrorResult { - msg: Notification::error(msg), - }) - } - - pub fn is_success(&self) -> bool { - matches!(self, Self::Success(_)) - } - - pub fn is_error(&self) -> bool { - matches!(self, Self::Error(_)) - } -} - -#[derive(Serialize, Deserialize, Display, Clone, Copy, VariantArray, PartialEq, Eq)] -pub enum Client { - #[serde(rename = "qBittorrent")] - #[strum(serialize = "qBittorrent")] - Qbit = 0, - - #[serde(rename = "Transmission")] - #[strum(serialize = "Transmission")] - Transmission = 1, - - #[serde(rename = "rqbit")] - #[strum(serialize = "rqbit")] - Rqbit = 2, - - #[serde(rename = "DefaultApp")] - #[strum(serialize = "Default App")] - DefaultApp = 3, - - #[serde(rename = "DownloadTorrentFile")] - #[strum(serialize = "Download Torrent File")] - Download = 4, - - #[serde(rename = "RunCommand")] - #[strum(serialize = "Run Command")] - Cmd = 5, -} - -#[derive(Default, Clone, Deserialize, Serialize)] -pub struct ClientConfig { - #[serde(rename = "command")] - pub cmd: Option, - #[serde(rename = "qBittorrent")] - pub qbit: Option, - #[serde(rename = "transmission")] - pub transmission: Option, - #[serde(rename = "default_app")] - pub default_app: Option, - #[serde(rename = "download")] - pub download: Option, - #[serde(rename = "rqbit")] - pub rqbit: Option, -} - -pub async fn multidownload( - success_msg: F, - items: &[Item], - conf: &ClientConfig, - client: &reqwest::Client, -) -> BatchDownloadResult -where - F: Fn(usize) -> String, -{ - let mut set = JoinSet::new(); - for item in items.iter() { - let item = item.to_owned(); - set.spawn(C::download(item.clone(), conf.clone(), client.clone())); - } - - let mut success_ids: Vec = vec![]; - let mut errors: Vec = vec![]; - while let Some(res) = set.join_next().await { - match res.unwrap_or_else(SingleDownloadResult::error) { - SingleDownloadResult::Success(sr) => success_ids.push(sr.id), - SingleDownloadResult::Error(er) => errors.push(er.msg), - } - } - - BatchDownloadResult { - msg: Notification::success(success_msg(success_ids.len())), - errors, - ids: success_ids, - } -} - -impl Client { - pub async fn download( - self, - item: Item, - conf: ClientConfig, - client: reqwest::Client, - ) -> SingleDownloadResult { - match self { - Self::Cmd => CmdClient::download(item, conf, client).await, - Self::DefaultApp => DefaultAppClient::download(item, conf, client).await, - Self::Download => DownloadFileClient::download(item, conf, client).await, - Self::Qbit => QbitClient::download(item, conf, client).await, - Self::Rqbit => RqbitClient::download(item, conf, client).await, - Self::Transmission => TransmissionClient::download(item, conf, client).await, - } - } - - pub async fn batch_download( - &self, - items: Vec, - conf: ClientConfig, - client: reqwest::Client, - ) -> BatchDownloadResult { - match self { - Self::Cmd => CmdClient::batch_download(items, conf, client).await, - Self::DefaultApp => DefaultAppClient::batch_download(items, conf, client).await, - Self::Download => DownloadFileClient::batch_download(items, conf, client).await, - Self::Qbit => QbitClient::batch_download(items, conf, client).await, - Self::Rqbit => RqbitClient::batch_download(items, conf, client).await, - Self::Transmission => TransmissionClient::batch_download(items, conf, client).await, - } - } - - pub fn load_config(self, cfg: &mut ClientConfig) { - match self { - Self::Cmd => CmdClient::load_config(cfg), - Self::DefaultApp => DefaultAppClient::load_config(cfg), - Self::Download => DownloadFileClient::load_config(cfg), - Self::Rqbit => RqbitClient::load_config(cfg), - Self::Qbit => QbitClient::load_config(cfg), - Self::Transmission => TransmissionClient::load_config(cfg), - }; - } -} diff --git a/src/client/cmd.rs b/src/client/cmd.rs deleted file mode 100644 index 49192d9..0000000 --- a/src/client/cmd.rs +++ /dev/null @@ -1,72 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::{source::Item, util::cmd::CommandBuilder}; - -use super::{ - multidownload, BatchDownloadResult, ClientConfig, DownloadClient, SingleDownloadResult, -}; - -#[derive(Serialize, Deserialize, Clone)] -#[serde(default)] -pub struct CmdConfig { - cmd: String, - shell_cmd: String, -} - -pub struct CmdClient; - -impl Default for CmdConfig { - fn default() -> Self { - CmdConfig { - #[cfg(windows)] - cmd: "curl \"{torrent}\" -o ~\\Downloads\\{file}".to_owned(), - #[cfg(unix)] - cmd: "curl \"{torrent}\" > ~/{file}".to_owned(), - - shell_cmd: CommandBuilder::default_shell(), - } - } -} - -impl DownloadClient for CmdClient { - async fn download(item: Item, conf: ClientConfig, _: reqwest::Client) -> SingleDownloadResult { - let cmd = match conf.cmd.to_owned() { - Some(c) => c, - None => { - return SingleDownloadResult::error("Failed to get cmd config"); - } - }; - let res = CommandBuilder::new(cmd.cmd) - .sub("{magnet}", &item.magnet_link) - .sub("{torrent}", &item.torrent_link) - .sub("{title}", &item.title) - .sub("{file}", &item.file_name) - .run(cmd.shell_cmd) - .map_err(|e| e.to_string()); - - match res { - Ok(()) => SingleDownloadResult::success("Successfully ran command", item.id), - Err(e) => SingleDownloadResult::error(e), - } - } - - async fn batch_download( - items: Vec, - conf: ClientConfig, - client: reqwest::Client, - ) -> BatchDownloadResult { - multidownload::( - |s| format!("Successfully ran command on {} torrents", s), - &items, - &conf, - &client, - ) - .await - } - - fn load_config(cfg: &mut ClientConfig) { - if cfg.cmd.is_none() { - cfg.cmd = Some(CmdConfig::default()); - } - } -} diff --git a/src/client/default_app.rs b/src/client/default_app.rs deleted file mode 100644 index 8ad6ff2..0000000 --- a/src/client/default_app.rs +++ /dev/null @@ -1,57 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::source::Item; - -use super::{ - multidownload, BatchDownloadResult, ClientConfig, DownloadClient, SingleDownloadResult, -}; - -#[derive(Serialize, Deserialize, Clone, Default)] -#[serde(default)] -pub struct DefaultAppConfig { - use_magnet: bool, -} - -pub struct DefaultAppClient; - -impl DownloadClient for DefaultAppClient { - async fn download(item: Item, conf: ClientConfig, _: reqwest::Client) -> SingleDownloadResult { - let conf = match conf.default_app.to_owned() { - Some(c) => c, - None => { - return SingleDownloadResult::error("Failed to get default app config"); - } - }; - let link = match conf.use_magnet { - true => item.magnet_link.to_owned(), - false => item.torrent_link.to_owned(), - }; - match open::that_detached(link).map_err(|e| e.to_string()) { - Ok(()) => { - SingleDownloadResult::success("Successfully opened link in default app", item.id) - } - Err(e) => SingleDownloadResult::error(e), - } - } - - async fn batch_download( - items: Vec, - conf: ClientConfig, - client: reqwest::Client, - ) -> BatchDownloadResult { - multidownload::( - |s| format!("Successfully opened {} links in default app", s), - &items, - &conf, - &client, - ) - .await - } - - fn load_config(cfg: &mut ClientConfig) { - if cfg.default_app.is_none() { - let def = DefaultAppConfig::default(); - cfg.default_app = Some(def); - } - } -} diff --git a/src/client/download.rs b/src/client/download.rs deleted file mode 100644 index 96f6040..0000000 --- a/src/client/download.rs +++ /dev/null @@ -1,141 +0,0 @@ -use std::{error::Error, fs, path::PathBuf}; - -use reqwest::StatusCode; -use serde::{Deserialize, Serialize}; - -use crate::{source::Item, util::conv::get_hash}; - -use super::{ - multidownload, BatchDownloadResult, ClientConfig, DownloadClient, SingleDownloadResult, -}; - -#[derive(Serialize, Deserialize, Clone)] -#[serde(default)] -pub struct DownloadConfig { - save_dir: String, - filename: Option, - overwrite: bool, - create_root_folder: bool, -} - -pub struct DownloadFileClient; - -impl Default for DownloadConfig { - fn default() -> Self { - let download_dir = match dirs::download_dir() { - Some(p) => p, - None => match dirs::home_dir() { - Some(h) => h.join("Downloads"), - None => PathBuf::from("./"), - }, - }; - DownloadConfig { - save_dir: download_dir.to_string_lossy().to_string(), - filename: None, - overwrite: true, - create_root_folder: true, - } - } -} - -async fn download_torrent( - torrent_link: String, - filename: String, - save_dir: String, - create_root_folder: bool, - overwrite: bool, - client: reqwest::Client, -) -> Result> { - let response = client.get(torrent_link.to_owned()).send().await?; - if response.status() != StatusCode::OK { - // Throw error if response code is not OK - let code = response.status().as_u16(); - return Err(format!("{}\nInvalid response code: {}", torrent_link, code).into()); - } - let content = response.bytes().await?; - let folder = PathBuf::from(shellexpand::full(&save_dir)?.to_string()); - let filepath = folder.join(filename); - if !overwrite && filepath.exists() { - return Err(format!( - "{} already exists.\nEnable \"overwrite\" to overwrite files", - filepath.to_string_lossy() - ) - .into()); - } - if create_root_folder && !folder.exists() { - fs::create_dir_all(folder)?; - } - fs::write(filepath.clone(), content)?; - Ok(filepath.to_string_lossy().to_string()) -} - -impl DownloadClient for DownloadFileClient { - async fn download( - item: Item, - conf: ClientConfig, - client: reqwest::Client, - ) -> SingleDownloadResult { - let conf = match conf.download.to_owned() { - Some(c) => c, - None => { - return SingleDownloadResult::error("Failed to get download config"); - } - }; - - let filename = conf - .filename - .map(|f| { - f.replace("{file}", &item.file_name) - .replace( - "{basename}", - item.file_name - .split_once(".torrent") - .map(|f| f.0) - .unwrap_or(&item.file_name), - ) - .replace( - "{hash}", - &get_hash(item.magnet_link).unwrap_or("NO_HASH_FOUND".to_string()), - ) - }) - .unwrap_or(item.file_name.to_owned()); - match download_torrent( - item.torrent_link.to_owned(), - filename, - conf.save_dir.clone(), - conf.create_root_folder, - conf.overwrite, - client, - ) - .await - { - Ok(path) => SingleDownloadResult::success(format!("Saved to \"{}\"", path), item.id), - Err(e) => SingleDownloadResult::error(format!( - "Failed to download torrent to {}:\n{}", - conf.save_dir.to_owned(), - e - )), - } - } - - async fn batch_download( - items: Vec, - conf: ClientConfig, - client: reqwest::Client, - ) -> BatchDownloadResult { - let save_dir = conf.download.clone().unwrap_or_default().save_dir.clone(); - multidownload::( - |s| format!("Saved {} torrents to folder {}", s, save_dir), - &items, - &conf, - &client, - ) - .await - } - - fn load_config(cfg: &mut ClientConfig) { - if cfg.download.is_none() { - cfg.download = Some(DownloadConfig::default()); - } - } -} diff --git a/src/client/qbit.rs b/src/client/qbit.rs deleted file mode 100644 index 9c80e94..0000000 --- a/src/client/qbit.rs +++ /dev/null @@ -1,257 +0,0 @@ -use std::{collections::HashMap, error::Error, fs}; - -use reqwest::{Response, StatusCode}; -use serde::{Deserialize, Serialize}; - -use crate::{source::Item, util::conv::add_protocol, widget::notifications::Notification}; - -use super::{BatchDownloadResult, ClientConfig, DownloadClient, SingleDownloadResult}; - -#[derive(Serialize, Deserialize, Clone)] -#[serde(default)] -pub struct QbitConfig { - pub base_url: String, - pub username: Option, - pub password: Option, - pub password_file: Option, - pub use_magnet: Option, - pub savepath: Option, - pub category: Option, // Single category - pub tags: Option>, // Comma separated joined - pub skip_checking: Option, - pub paused: Option, - pub create_root_folder: Option, // root_folder: String - pub up_limit: Option, - pub dl_limit: Option, - pub ratio_limit: Option, - pub seeding_time_limit: Option, - pub auto_tmm: Option, - pub sequential_download: Option, // String - pub prioritize_first_last_pieces: Option, // String -} - -pub struct QbitClient; - -impl QbitConfig { - fn to_form(&self, url: String) -> QbitForm { - QbitForm { - urls: url, - savepath: self.savepath.to_owned(), - category: self.category.to_owned(), - tags: self.tags.clone().map(|v| v.join(",")), - skip_checking: self.skip_checking.map(|b| b.to_string()), - paused: self.paused.map(|b| b.to_string()), - root_folder: self.create_root_folder.map(|b| b.to_string()), - up_limit: self.up_limit, - dl_limit: self.dl_limit, - ratio_limit: self.ratio_limit, - seeding_time_limit: self.seeding_time_limit, - auto_tmm: self.auto_tmm, - sequential_download: self.sequential_download.map(|b| b.to_string()), - first_last_piece_prio: self.prioritize_first_last_pieces.map(|b| b.to_string()), - } - } -} - -impl Default for QbitConfig { - fn default() -> Self { - Self { - base_url: "http://localhost:8080".to_owned(), - username: None, - password: None, - password_file: None, - use_magnet: None, - savepath: None, - category: None, - tags: None, - skip_checking: None, - paused: None, - create_root_folder: None, - up_limit: None, - dl_limit: None, - ratio_limit: None, - seeding_time_limit: None, - auto_tmm: None, - sequential_download: None, - prioritize_first_last_pieces: None, - } - } -} - -#[derive(Serialize, Deserialize, Clone)] -struct QbitForm { - #[serde(rename = "urls")] - urls: String, - #[serde(rename = "savepath")] - savepath: Option, - #[serde(rename = "category")] - category: Option, - #[serde(rename = "tags")] - tags: Option, - #[serde(rename = "skip_checking")] - skip_checking: Option, - #[serde(rename = "paused")] - paused: Option, - #[serde(rename = "root_folder")] - root_folder: Option, - #[serde(rename = "upLimit")] - up_limit: Option, - #[serde(rename = "dlLimit")] - dl_limit: Option, - #[serde(rename = "ratioLimit")] - ratio_limit: Option, - #[serde(rename = "seedingTimeLimit")] - seeding_time_limit: Option, - #[serde(rename = "autoTMM")] - auto_tmm: Option, - #[serde(rename = "sequentialDownload")] - sequential_download: Option, - #[serde(rename = "firstLastPiecePrio")] - first_last_piece_prio: Option, - // torrents: Raw // Disabled - // cookie: String // Disabled - // rename: String // Disabled -} - -async fn login( - qbit: &QbitConfig, - client: &reqwest::Client, -) -> Result<(), Box> { - let pass = match qbit.password.as_ref() { - Some(pass) => Some(pass.to_owned()), - None => match qbit.password_file.as_ref() { - Some(file) => { - let contents = fs::read_to_string(file)?; - let expand = shellexpand::full(contents.trim())?; - Some(expand.to_string()) - } - None => None, - }, - }; - if let (Some(user), Some(pass)) = (qbit.username.as_ref(), pass) { - let base_url = add_protocol(qbit.base_url.clone(), false)?; - let url = base_url.join("/api/v2/auth/login")?; - let mut params = HashMap::new(); - params.insert("username", user); - params.insert("password", &pass); - let _ = client.post(url).form(¶ms).send().await?; - } - Ok(()) -} - -async fn logout( - qbit: &QbitConfig, - client: &reqwest::Client, -) -> Result<(), Box> { - let base_url = add_protocol(qbit.base_url.clone(), false)?; - let url = base_url.join("/api/v2/auth/logout")?; - let _ = client.get(url).send().await; - Ok(()) -} - -async fn add_torrent( - qbit: &QbitConfig, - links: String, - client: &reqwest::Client, -) -> Result> { - let base_url = add_protocol(qbit.base_url.clone(), false)?; - let url = base_url.join("/api/v2/torrents/add")?; - - Ok(client.post(url).form(&qbit.to_form(links)).send().await?) -} - -async fn download_some( - items: Vec, - conf: ClientConfig, - client: reqwest::Client, -) -> Result<(), String> { - let Some(qbit) = conf.qbit.to_owned() else { - return Err("Failed to get qBittorrent config".to_owned()); - }; - if let Some(labels) = qbit.tags.clone() { - if let Some(bad) = labels.iter().find(|l| l.contains(',')) { - let bad = format!("\"{}\"", bad); - return Err(format!( - "qBittorrent tags must not contain commas:\n{}", - bad - )); - } - } - if let Err(e) = login(&qbit, &client).await { - return Err(format!("Failed to get SID:\n{}", e)); - } - let links = match qbit.use_magnet.unwrap_or(true) { - true => items - .iter() - .map(|i| i.magnet_link.to_owned()) - .collect::>() - .join("\n"), - false => items - .iter() - .map(|i| i.torrent_link.to_owned()) - .collect::>() - .join("\n"), - }; - let res = match add_torrent(&qbit, links, &client).await { - Ok(res) => res, - Err(e) => return Err(format!("Failed to get response:\n{}", e)), - }; - if res.status() != StatusCode::OK { - let mut msg = format!( - "qBittorrent returned status code {} {}", - res.status().as_u16(), - res.status().canonical_reason().unwrap_or("") - ); - if res.status() == StatusCode::FORBIDDEN { - msg.push_str("\n\nLikely incorrect username/password"); - } - return Err(msg); - } - - let _ = logout(&qbit, &client).await; - Ok(()) -} - -impl DownloadClient for QbitClient { - async fn download( - item: Item, - conf: ClientConfig, - client: reqwest::Client, - ) -> SingleDownloadResult { - let id = item.id.clone(); - match download_some(vec![item], conf, client).await { - Ok(()) => SingleDownloadResult::success("Successfully sent torrent to qBittorrent", id), - Err(e) => SingleDownloadResult::error(e), - } - } - - async fn batch_download( - items: Vec, - conf: ClientConfig, - client: reqwest::Client, - ) -> BatchDownloadResult { - let ids = items.iter().map(|i| i.id.clone()).collect(); - let num_items = items.len(); - match download_some(items, conf, client).await { - Ok(()) => BatchDownloadResult { - msg: Notification::success("Successfully sent {} torrents to qBittorrent"), - ids, - errors: vec![], - }, - Err(e) => BatchDownloadResult { - msg: Notification::error(format!( - "Failed to send {} torrents to qBittorrent", - num_items - )), - errors: vec![Notification::error(e)], - ids: vec![], - }, - } - } - - fn load_config(cfg: &mut ClientConfig) { - if cfg.qbit.is_none() { - cfg.qbit = Some(QbitConfig::default()); - } - } -} diff --git a/src/client/rqbit.rs b/src/client/rqbit.rs deleted file mode 100644 index 8c7fdb0..0000000 --- a/src/client/rqbit.rs +++ /dev/null @@ -1,118 +0,0 @@ -use std::error::Error; - -use reqwest::{Response, StatusCode}; -use serde::{Deserialize, Serialize}; -use urlencoding::encode; - -use crate::{source::Item, util::conv::add_protocol}; - -use super::{ - multidownload, BatchDownloadResult, ClientConfig, DownloadClient, DownloadError, - SingleDownloadResult, -}; - -#[derive(Serialize, Deserialize, Clone)] -#[serde(default)] -pub struct RqbitConfig { - pub base_url: String, - pub use_magnet: Option, - pub overwrite: Option, - pub output_folder: Option, -} - -pub struct RqbitClient; - -#[derive(Serialize, Deserialize, Clone)] -pub struct RqbitForm { - pub overwrite: Option, - pub output_folder: Option, -} - -impl Default for RqbitConfig { - fn default() -> Self { - Self { - base_url: "http://localhost:3030".to_owned(), - use_magnet: None, - overwrite: None, - output_folder: None, - } - } -} - -async fn add_torrent( - conf: &RqbitConfig, - link: String, - client: &reqwest::Client, -) -> Result> { - let base_url = add_protocol(conf.base_url.clone(), false)?; - let mut url = base_url.join("/torrents")?; - let mut query: Vec = vec![]; - if let Some(ow) = conf.overwrite { - query.push(format!("overwrite={}", ow)); - } - if let Some(out) = conf.output_folder.to_owned() { - query.push(format!("output_folder={}", encode(&out))); - } - url.set_query(Some(&query.join("&"))); - - match client.post(url).body(link).send().await { - Ok(res) => Ok(res), - Err(e) => Err(e.into()), - } -} - -impl DownloadClient for RqbitClient { - async fn download( - item: Item, - conf: ClientConfig, - client: reqwest::Client, - ) -> SingleDownloadResult { - let conf = match conf.rqbit.clone() { - Some(q) => q, - None => { - return SingleDownloadResult::error("Failed to get rqbit config"); - } - }; - let link = match conf.use_magnet.unwrap_or(true) { - true => item.magnet_link.to_owned(), - false => item.torrent_link.to_owned(), - }; - let res = match add_torrent(&conf, link, &client).await { - Ok(r) => r, - Err(e) => { - return SingleDownloadResult::error(DownloadError(format!( - "Failed to get response from rqbit\n{}", - e - ))); - } - }; - if res.status() != StatusCode::OK { - return SingleDownloadResult::error(DownloadError(format!( - "rqbit returned status code {}", - res.status().as_u16() - ))); - } - - SingleDownloadResult::success("Successfully sent torrent to rqbit".to_owned(), item.id) - } - - async fn batch_download( - items: Vec, - conf: ClientConfig, - client: reqwest::Client, - ) -> BatchDownloadResult { - multidownload::( - |s| format!("Successfully sent {} torrents to rqbit", s), - &items, - &conf, - &client, - ) - .await - } - - fn load_config(cfg: &mut ClientConfig) { - if cfg.rqbit.is_none() { - cfg.rqbit = Some(RqbitConfig::default()); - } - } -} diff --git a/src/client/transmission.rs b/src/client/transmission.rs deleted file mode 100644 index 4e81d79..0000000 --- a/src/client/transmission.rs +++ /dev/null @@ -1,145 +0,0 @@ -use std::{error::Error, fs}; - -use serde::{Deserialize, Serialize}; -use transmission_rpc::{ - types::{BasicAuth, Priority, TorrentAddArgs}, - TransClient, -}; - -use crate::{source::Item, util::conv::add_protocol}; - -use super::{ - multidownload, BatchDownloadResult, ClientConfig, DownloadClient, SingleDownloadResult, -}; - -#[derive(Serialize, Deserialize, Clone)] -#[serde(default)] -pub struct TransmissionConfig { - pub base_url: String, - pub username: Option, - pub password: Option, - pub password_file: Option, - pub use_magnet: Option, - pub labels: Option>, - pub paused: Option, - pub peer_limit: Option, - pub download_dir: Option, - pub bandwidth_priority: Option, -} - -pub struct TransmissionClient; - -impl Default for TransmissionConfig { - fn default() -> Self { - Self { - base_url: "http://localhost:9091/transmission/rpc".to_owned(), - username: None, - password: None, - password_file: None, - use_magnet: None, - labels: None, - paused: None, - peer_limit: None, - download_dir: None, - bandwidth_priority: None, - } - } -} - -impl TransmissionConfig { - fn form(self, link: String) -> TorrentAddArgs { - TorrentAddArgs { - filename: Some(link), - labels: self.labels, - paused: self.paused, - peer_limit: self.peer_limit, - download_dir: self.download_dir, - bandwidth_priority: self.bandwidth_priority, - ..Default::default() - } - } -} - -async fn add_torrent( - conf: TransmissionConfig, - link: String, - client: reqwest::Client, -) -> Result<(), Box> { - let base_url = add_protocol(conf.base_url.clone(), false)?; - let mut client = TransClient::new_with_client(base_url, client); - - let pass = match conf.password.as_ref() { - Some(pass) => Some(pass.clone()), - None => match conf.password_file.as_ref() { - Some(file) => { - let contents = fs::read_to_string(file)?; - let expand = shellexpand::full(contents.trim())?; - Some(expand.to_string()) - } - None => None, - }, - }; - if let (Some(user), Some(password)) = (conf.username.as_ref(), pass.as_ref()) { - client.set_auth(BasicAuth { - user: user.clone(), - password: password.clone(), - }); - } - let add = conf.form(link); - client - .torrent_add(add) - .await - .map_err(|e| format!("Failed to add torrent:\n{}", e))?; - Ok(()) -} - -impl DownloadClient for TransmissionClient { - async fn download( - item: Item, - conf: ClientConfig, - client: reqwest::Client, - ) -> SingleDownloadResult { - let Some(conf) = conf.transmission.clone() else { - return SingleDownloadResult::error("Failed to get configuration for transmission"); - }; - - if let Some(labels) = conf.labels.clone() { - if let Some(bad) = labels.iter().find(|l| l.contains(',')) { - let bad = format!("\"{}\"", bad); - return SingleDownloadResult::error(format!( - "Transmission labels must not contain commas:\n{}", - bad - )); - } - } - - let link = match conf.use_magnet { - None | Some(true) => item.magnet_link.to_owned(), - Some(false) => item.torrent_link.to_owned(), - }; - if let Err(e) = add_torrent(conf, link, client).await { - return SingleDownloadResult::error(e); - } - SingleDownloadResult::success("Successfully sent torrent to Transmission", item.id) - } - - async fn batch_download( - items: Vec, - conf: ClientConfig, - client: reqwest::Client, - ) -> BatchDownloadResult { - multidownload::( - |s| format!("Successfully sent {} torrents to Transmission", s), - &items, - &conf, - &client, - ) - .await - } - - fn load_config(cfg: &mut ClientConfig) { - if cfg.transmission.is_none() { - cfg.transmission = Some(TransmissionConfig::default()); - } - } -} diff --git a/tests/config/config.toml b/src/clients.rs similarity index 100% rename from tests/config/config.toml rename to src/clients.rs diff --git a/src/clients/qBittorrent.rs b/src/clients/qBittorrent.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/clip.rs b/src/clip.rs deleted file mode 100644 index edc346e..0000000 --- a/src/clip.rs +++ /dev/null @@ -1,168 +0,0 @@ -#[cfg(target_os = "linux")] -use arboard::{GetExtLinux, LinuxClipboardKind, SetExtLinux as _}; - -#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] -use arboard::Clipboard; -use base64::Engine; -use serde::{Deserialize, Serialize}; - -use crate::util::{cmd::CommandBuilder, types::OneOrMany}; - -#[derive(Default, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum Selection { - #[default] - Clipboard, - Primary, - Secondary, -} - -#[cfg(target_os = "linux")] -impl Selection { - fn get_kind(&self) -> LinuxClipboardKind { - match self { - Self::Primary => LinuxClipboardKind::Primary, - Self::Clipboard => LinuxClipboardKind::Clipboard, - Self::Secondary => LinuxClipboardKind::Secondary, - } - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct ClipboardConfig { - pub cmd: Option, - pub shell_cmd: Option, - pub osc52: bool, - pub selection: Option>, -} - -impl Default for ClipboardConfig { - fn default() -> Self { - Self { - cmd: None, - shell_cmd: None, - osc52: true, - selection: None, - } - } -} - -pub struct ClipboardManager { - #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] - clipboard: Option, - config: ClipboardConfig, -} - -impl ClipboardManager { - pub fn new(conf: ClipboardConfig) -> (ClipboardManager, Option) { - // Dont worry about connecting to OS clipboard if using command - if conf.cmd.is_some() || conf.osc52 { - #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] - return ( - Self { - clipboard: None, - config: conf, - }, - None, - ); - #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] - return (Self { config: conf }, None); - } - - #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] - { - let clipboard = Clipboard::new(); - let err = clipboard.as_ref().err().map(|x| x.to_string()); - let cb = clipboard.ok(); - ( - Self { - clipboard: cb, - config: conf, - }, - err, - ) - } - #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] - { - (Self { config: conf }, None) - } - } - - pub fn empty(conf: ClipboardConfig) -> (ClipboardManager, Option) { - #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] - { - ( - Self { - clipboard: None, - config: conf, - }, - None, - ) - } - #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] - { - (Self { config: conf }, None) - } - } - - pub fn try_copy(&mut self, content: &String) -> Result<(), String> { - if let Some(cmd) = self.config.cmd.clone() { - return CommandBuilder::new(cmd) - .sub("{content}", content) - .run(self.config.shell_cmd.clone()) - .map_err(|e| e.to_string()); - } - if self.config.osc52 { - print!( - "\x1B]52;c;{}\x07", - base64::engine::general_purpose::STANDARD.encode(content) - ); - - return Ok(()); - } - #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] - match &mut self.clipboard { - // Some(cb) => Ok(cb.set_text(content)?), - Some(cb) => Self::copy(&self.config, cb, content).map_err(|e| e.to_string()), - None => Err("The clipboard is not loaded".to_owned()), - } - #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] - Err( - "There is no native clipboard support\nTry enabling osc52 for clipboard support" - .to_owned(), - ) - } - - #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] - fn copy( - #[cfg(target_os = "linux")] config: &ClipboardConfig, - #[cfg(not(target_os = "linux"))] _config: &ClipboardConfig, - clipboard: &mut Clipboard, - content: &String, - ) -> Result<(), String> { - #[cfg(target_os = "linux")] - { - if let Some(selections) = config.selection.to_owned() { - let errors = selections - .vec() - .iter() - .map(Selection::get_kind) - .filter_map(|t| { - let res = clipboard.set().clipboard(t).text(content); - let _ = clipboard.get().clipboard(t).text(); - res.err() - .map(|e| format!("Failed to copy to \"{t:?}\" selection:\n{e}")) - }) - .collect::>(); - if !errors.is_empty() { - return Err(errors.join("\n\n")); - } - return Ok(()); - } - } - clipboard - .set_text(content) - .map_err(|e| format!("Failed to copy:\n{e}"))?; - let _ = clipboard.get_text(); - Ok(()) - } -} diff --git a/src/clip/bin.rs b/src/clip/bin.rs deleted file mode 100644 index 8b13789..0000000 --- a/src/clip/bin.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/components.rs b/src/components.rs new file mode 100644 index 0000000..a9507e7 --- /dev/null +++ b/src/components.rs @@ -0,0 +1,14 @@ +use color_eyre::Result; +use ratatui::{layout::Rect, Frame}; + +use crate::action::AppAction; + +pub mod actions_temp; +pub mod home; +pub mod results; + +// TODO: simple component for now +pub trait Component { + fn update(&mut self, action: &AppAction) -> Result>; + fn render(&mut self, frame: &mut Frame, area: Rect) -> Result<()>; +} diff --git a/src/components/actions_temp.rs b/src/components/actions_temp.rs new file mode 100644 index 0000000..86ce21c --- /dev/null +++ b/src/components/actions_temp.rs @@ -0,0 +1,39 @@ +use color_eyre::Result; +use ratatui::{ + layout::Rect, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use crate::action::{AppAction, UserAction}; + +use super::Component; + +pub struct ActionsComponent { + actions: Vec, +} + +impl ActionsComponent { + pub fn new() -> Self { + Self { + actions: Vec::new(), + } + } +} + +impl Component for ActionsComponent { + fn update(&mut self, action: &AppAction) -> Result> { + if let AppAction::UserAction(UserAction::SetMode(m)) = action { + self.actions.push(m.to_string()); + } + Ok(None) + } + + fn render(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let p = Paragraph::new(self.actions.join("\n")) + .block(Block::new().borders(Borders::ALL).title("User Actions")); + frame.render_widget(p, area); + + Ok(()) + } +} diff --git a/src/components/batch.rs b/src/components/batch.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/components/home.rs b/src/components/home.rs new file mode 100644 index 0000000..56ea64f --- /dev/null +++ b/src/components/home.rs @@ -0,0 +1,42 @@ +use color_eyre::Result; +use ratatui::{ + layout::{Constraint, Layout, Rect}, + Frame, +}; + +use crate::action::AppAction; + +use super::{actions_temp::ActionsComponent, results::ResultsComponent, Component}; + +pub struct HomeComponent { + results: ResultsComponent, + actions_temp: ActionsComponent, + // batch: BatchComponent, + // search: SearchComponent, +} + +impl HomeComponent { + pub fn new() -> Self { + Self { + results: ResultsComponent::new(), + actions_temp: ActionsComponent::new(), + } + } +} + +impl Component for HomeComponent { + fn update(&mut self, action: &AppAction) -> Result> { + self.results.update(action)?; + self.actions_temp.update(action)?; + Ok(None) + } + + fn render(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let layout = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + self.results.render(frame, layout[0])?; + self.actions_temp.render(frame, layout[1])?; + + Ok(()) + } +} diff --git a/src/components/results.rs b/src/components/results.rs new file mode 100644 index 0000000..01a574c --- /dev/null +++ b/src/components/results.rs @@ -0,0 +1,38 @@ +use color_eyre::Result; +use ratatui::{ + layout::Rect, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use crate::action::AppAction; + +use super::Component; + +pub struct ResultsComponent { + content: String, +} + +impl ResultsComponent { + pub fn new() -> Self { + Self { + content: "Hello, results".to_string(), + } + } +} + +impl Component for ResultsComponent { + fn update(&mut self, action: &AppAction) -> color_eyre::eyre::Result> { + if action == &AppAction::Resume { + self.content = "Welcome back, user".to_string(); + } + Ok(None) + } + + fn render(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let p = + Paragraph::new(format!("{}!", self.content)).block(Block::new().borders(Borders::ALL)); + frame.render_widget(p, area); + Ok(()) + } +} diff --git a/src/components/search.rs b/src/components/search.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/config.rs b/src/config.rs index d207a3f..59617a6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,223 +1,24 @@ -use std::{ - error::Error, - fs::{self, File, OpenOptions}, - io::{ErrorKind, Read, Write as _}, - path::{Path, PathBuf}, - str::FromStr, -}; +use color_eyre::Result; -use crate::{ - app::{Context, Widgets, APP_NAME}, - client::{Client, ClientConfig}, - clip::ClipboardConfig, - source::{SourceConfig, Sources}, - theme::{self, Theme}, - widget::notifications::NotificationConfig, -}; -use directories::ProjectDirs; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use crate::{keys::KeyBindings, themes::Theme}; -pub static CONFIG_FILE: &str = "config.toml"; +const DEFAULT_KEYBINDS_TOML: &str = include_str!("../.default-config/keybinds.toml"); -pub trait ConfigManager { - fn load(&self) -> Result>; - fn store(&self, cfg: &Config) -> Result<(), Box>; - fn path(&self) -> PathBuf; -} - -pub struct AppConfig { - config_path: PathBuf, -} +pub struct AppConfig {} -impl AppConfig { - pub fn new() -> Result> { - Ok(Self { - config_path: get_configuration_folder(APP_NAME)?, - }) - } - pub fn from_path(config_path: String) -> Result> { - Ok(Self { - config_path: PathBuf::from_str(&config_path)?, - }) - } -} - -#[derive(Serialize, Deserialize, Clone)] -#[serde(default)] pub struct Config { - #[serde(alias = "default_theme")] - pub theme: String, - #[serde(rename = "default_source")] - pub source: Sources, - pub download_client: Client, - pub date_format: Option, - pub relative_date: Option, - pub relative_date_short: Option, - pub request_proxy: Option, - pub timeout: u64, - pub scroll_padding: usize, - pub cursor_padding: usize, - pub save_config_on_change: bool, - pub hot_reload_config: bool, - - #[serde(rename = "notifications")] - pub notifications: Option, - #[serde(rename = "clipboard")] - pub clipboard: Option, - #[serde(rename = "client")] - pub client: ClientConfig, - #[serde(rename = "source")] - pub sources: SourceConfig, -} - -impl Default for Config { - fn default() -> Config { - Config { - source: Sources::Nyaa, - download_client: Client::Cmd, - theme: Theme::default().name, - date_format: None, - relative_date: None, - relative_date_short: None, - request_proxy: None, - timeout: 30, - scroll_padding: 3, - cursor_padding: 4, - save_config_on_change: true, - hot_reload_config: true, - - notifications: None, - clipboard: None, - client: ClientConfig::default(), - sources: SourceConfig::default(), - } - } -} - -impl ConfigManager for AppConfig { - fn load(&self) -> Result> { - load_path(self.config_path.join(CONFIG_FILE)) - } - fn store(&self, cfg: &Config) -> Result<(), Box> { - store_path(self.config_path.join(CONFIG_FILE), cfg) - } - fn path(&self) -> PathBuf { - self.config_path.clone() - } + pub app: AppConfig, + pub keys: KeyBindings, + pub themes: Vec, } impl Config { - pub fn full_apply( - &self, - path: PathBuf, - ctx: &mut Context, - w: &mut Widgets, - ) -> Result<(), Box> { - // Load user-defined themes - theme::load_user_themes(ctx, path)?; - - self.partial_apply(ctx, w)?; - - // Set download client - ctx.client = ctx.config.download_client; - // Set source - ctx.src = ctx.config.source; - // Set source info (categories, etc.) - ctx.src_info = ctx.src.info(); - - ctx.src.apply(ctx, w); - if let Some(conf) = ctx.config.notifications { - w.notification.load_config(&conf); - } - - w.clients.table.select(ctx.client as usize); - - // Load defaults for default source - Ok(()) - } - - pub fn partial_apply(&self, ctx: &mut Context, w: &mut Widgets) -> Result<(), Box> { - ctx.config = self.clone(); - - // Set selected theme - if let Some((i, _, theme)) = ctx.themes.get_full(&self.theme) { - w.theme.selected = i; - w.theme.table.select(i); - ctx.theme = theme.clone(); - } - - // Load download client config - ctx.client.load_config(&mut ctx.config.client); - - // Load current source config - ctx.src.load_config(&mut ctx.config.sources); - - Ok(()) - } -} - -pub fn load_path( - path: impl AsRef, -) -> Result> { - let path = path.as_ref(); - match File::open(path) { - Ok(mut cfg) => { - let mut cfg_string = String::new(); - cfg.read_to_string(&mut cfg_string) - .map_err(|e| format!("{path:?}\nUnable to read file:\n{e}"))?; - - let cfg_data = toml::from_str(&cfg_string); - let data = cfg_data?; - Ok(data) - } - Err(ref e) if e.kind() == ErrorKind::NotFound => { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let cfg = T::default(); - store_path(path, &cfg)?; - Ok(cfg) - } - Err(e) => Err(e.into()), + pub fn new() -> Result { + Ok(Self { + // TODO: load from disk + app: AppConfig {}, + keys: toml::from_str(DEFAULT_KEYBINDS_TOML)?, + themes: vec![], + }) } } - -fn store_path(path: impl AsRef, cfg: impl Serialize) -> Result<(), Box> { - let path = path.as_ref(); - let config_dir = path - .parent() - .ok_or(format!("{path:?} is a root or prefix"))?; - fs::create_dir_all(config_dir)?; - - let s = toml::to_string_pretty(&cfg)?; - - let mut f = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(path)?; - - f.write_all(s.as_bytes())?; - Ok(()) -} - -pub fn get_configuration_file_path<'a>( - app_name: &str, - config_name: impl Into>, -) -> Result> { - let config_name: &str = Into::>::into(config_name).unwrap_or("config"); - let path = get_configuration_folder(app_name)?.join(format!("{config_name}.toml")); - Ok(path) -} - -pub fn get_configuration_folder(app_name: &str) -> Result> { - let project = ProjectDirs::from("rs", "", app_name) - .ok_or("could not determine home directory path".to_string())?; - - let path = project.config_dir(); - let config_dir_str = path - .to_str() - .ok_or(format!("{path:?} is not valid Unicode"))?; - - Ok(config_dir_str.into()) -} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/keys.rs b/src/keys.rs new file mode 100644 index 0000000..6d6c48d --- /dev/null +++ b/src/keys.rs @@ -0,0 +1,292 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use derive_deref::{Deref, DerefMut}; +use indexmap::IndexMap; +use serde::{Deserialize, Deserializer}; + +use crate::{action::UserAction, app::Mode}; + +#[derive(Clone, Debug, Default, Deref, DerefMut)] +pub struct KeyBindings(pub IndexMap, UserAction>>); + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum UserActionWrapped { + Unit(UserAction), + Other(String), +} + +impl<'de> Deserialize<'de> for KeyBindings { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let parsed_map = + IndexMap::>::deserialize(deserializer)?; + + let keybindings = parsed_map + .into_iter() + .map(|(mode, inner_map)| { + let converted_inner_map = inner_map + .into_iter() + .map(|(key_str, cmd)| (key_str, parse_wrapped_actions(cmd).unwrap())) + .map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd)) + .collect(); + (mode, converted_inner_map) + }) + .collect(); + + Ok(KeyBindings(keybindings)) + } +} + +fn parse_wrapped_actions(cmd: UserActionWrapped) -> Result { + Ok(match cmd { + UserActionWrapped::Unit(unit) => unit, + UserActionWrapped::Other(other) => parse_other_action_simple(other)?, + }) +} + +// TODO: For now, only handle simple case of Enum1(Enum2) or Enum1(String) +fn parse_other_action_simple(other: String) -> Result { + let tokens = other.split_ascii_whitespace().collect::>(); + match tokens.as_slice() { + [key, val] => { + let mut table = toml::Table::new(); + table.insert(key.to_string(), toml::Value::String(val.to_string())); + Ok(table.try_into().unwrap()) + } + _ => Err("UserAction is not properly formatted".to_string()), + } +} + +// // TODO: More complex case +// fn parse_other_action(other: String) -> UserAction { +// // TODO: more complex parsing, with handling of quotes +// let mut tokens = other +// .split_whitespace() +// .map(ToString::to_string) +// .collect::>(); +// +// let val = tokens.pop().unwrap(); // TODO: handle invalid cases +// let key = tokens.pop().unwrap(); +// +// let mut initial_table = toml::Table::new(); +// initial_table.insert(key, toml::Value::String(val)); +// let mut table: toml::Value = toml::Value::Table(initial_table); +// +// while let Some(new_key) = tokens.pop() { +// let mut new_table = toml::Table::new(); +// new_table.insert(new_key, table.clone()); +// table = toml::Value::Table(new_table); +// } +// +// match table { +// toml::Value::Table(m) => m.try_into().unwrap(), +// _ => panic!("ermmmm"), +// } +// } + +fn parse_key_event(raw: &str) -> Result { + // let raw_lower = raw.to_ascii_lowercase(); + let (remaining, modifiers) = extract_modifiers(raw); + parse_key_code_with_modifiers(remaining, modifiers) +} + +fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) { + let mut modifiers = KeyModifiers::empty(); + let mut current = raw; + let lower_string = raw.to_ascii_lowercase(); + let mut lower = lower_string.as_str(); + + loop { + match (lower, current) { + (rest, rest_upper) if rest.starts_with("ctrl-") => { + modifiers.insert(KeyModifiers::CONTROL); + current = &rest_upper[5..]; + lower = &rest[5..]; + } + (rest, rest_upper) if rest.starts_with("alt-") => { + modifiers.insert(KeyModifiers::ALT); + current = &rest_upper[4..]; + lower = &rest[4..]; + } + (rest, rest_upper) if rest.starts_with("shift-") => { + modifiers.insert(KeyModifiers::SHIFT); + current = &rest_upper[6..]; + lower = &rest[6..]; + } + // Shorthand versions + (rest, rest_upper) if rest.starts_with("c-") => { + modifiers.insert(KeyModifiers::CONTROL); + current = &rest_upper[2..]; + lower = &rest[2..]; + } + (rest, rest_upper) if rest.starts_with("a-") => { + modifiers.insert(KeyModifiers::ALT); + current = &rest_upper[2..]; + lower = &rest[2..]; + } + (rest, rest_upper) if rest.starts_with("s-") => { + modifiers.insert(KeyModifiers::SHIFT); + current = &rest_upper[2..]; + lower = &rest[2..]; + } + _ => break, // break out of the loop if no known prefix is detected + }; + } + + (current, modifiers) +} + +fn parse_key_code_with_modifiers( + raw: &str, + mut modifiers: KeyModifiers, +) -> Result { + let lower = raw.to_ascii_lowercase(); + let c = match lower.as_str() { + "esc" => KeyCode::Esc, + "enter" => KeyCode::Enter, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + // NOTE: Not used, S-tab is equivalent + // "backtab" => { + // modifiers.insert(KeyModifiers::SHIFT); + // KeyCode::BackTab + // } + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "insert" => KeyCode::Insert, + "f1" => KeyCode::F(1), + "f2" => KeyCode::F(2), + "f3" => KeyCode::F(3), + "f4" => KeyCode::F(4), + "f5" => KeyCode::F(5), + "f6" => KeyCode::F(6), + "f7" => KeyCode::F(7), + "f8" => KeyCode::F(8), + "f9" => KeyCode::F(9), + "f10" => KeyCode::F(10), + "f11" => KeyCode::F(11), + "f12" => KeyCode::F(12), + "space" => KeyCode::Char(' '), + "hyphen" => KeyCode::Char('-'), + "minus" => KeyCode::Char('-'), + "lt" => KeyCode::Char('<'), + "gt" => KeyCode::Char('>'), + // "tab" => KeyCode::Tab, + "tab" => { + if modifiers.contains(KeyModifiers::SHIFT) { + KeyCode::BackTab + } else { + KeyCode::Tab + } + } + c if c.len() == 1 => { + let mut c = raw.chars().next().unwrap(); + if c.is_ascii_uppercase() { + modifiers.insert(KeyModifiers::SHIFT) + } else if modifiers.contains(KeyModifiers::SHIFT) { + c = c.to_ascii_uppercase(); + } + KeyCode::Char(c) + } + _ => return Err(format!("Unable to parse {raw}")), + }; + Ok(KeyEvent::new(c, modifiers)) +} + +pub fn parse_key_sequence(raw: &str) -> Result, String> { + if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { + return Err(format!("Unable to parse `{}`", raw)); + } + let raw = if !raw.contains("><") { + let raw = raw.strip_prefix('<').unwrap_or(raw); + let raw = raw.strip_prefix('>').unwrap_or(raw); + raw + } else { + raw + }; + let sequences = raw + .split("><") + .map(|seq| { + if let Some(s) = seq.strip_prefix('<') { + s + } else if let Some(s) = seq.strip_suffix('>') { + s + } else { + seq + } + }) + .collect::>(); + + sequences.into_iter().map(parse_key_event).collect() +} + +// pub fn key_event_to_string(key_event: &KeyEvent) -> String { +// let char; +// let key_code = match key_event.code { +// KeyCode::Backspace => "backspace", +// KeyCode::Enter => "enter", +// KeyCode::Left => "left", +// KeyCode::Right => "right", +// KeyCode::Up => "up", +// KeyCode::Down => "down", +// KeyCode::Home => "home", +// KeyCode::End => "end", +// KeyCode::PageUp => "pageup", +// KeyCode::PageDown => "pagedown", +// KeyCode::Tab => "tab", +// KeyCode::BackTab => "backtab", +// KeyCode::Delete => "delete", +// KeyCode::Insert => "insert", +// KeyCode::F(c) => { +// char = format!("f({c})"); +// &char +// } +// KeyCode::Char(' ') => "space", +// KeyCode::Char(c) => { +// char = c.to_string(); +// &char +// } +// KeyCode::Esc => "esc", +// KeyCode::Null => "", +// KeyCode::CapsLock => "", +// KeyCode::Menu => "", +// KeyCode::ScrollLock => "", +// KeyCode::Media(_) => "", +// KeyCode::NumLock => "", +// KeyCode::PrintScreen => "", +// KeyCode::Pause => "", +// KeyCode::KeypadBegin => "", +// KeyCode::Modifier(_) => "", +// }; +// +// let mut modifiers = Vec::with_capacity(3); +// +// if key_event.modifiers.intersects(KeyModifiers::CONTROL) { +// modifiers.push("ctrl"); +// } +// +// if key_event.modifiers.intersects(KeyModifiers::SHIFT) { +// modifiers.push("shift"); +// } +// +// if key_event.modifiers.intersects(KeyModifiers::ALT) { +// modifiers.push("alt"); +// } +// +// let mut key = modifiers.join("-"); +// +// if !key.is_empty() { +// key.push('-'); +// } +// key.push_str(key_code); +// +// key +// } diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 0a357ef..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -pub mod app; -pub mod client; -pub mod clip; -pub mod config; -pub mod macros; -pub mod results; -pub mod source; -pub mod sync; -pub mod theme; -pub mod util; -pub mod widget; diff --git a/src/macros.rs b/src/macros.rs deleted file mode 100644 index c442156..0000000 --- a/src/macros.rs +++ /dev/null @@ -1,187 +0,0 @@ -#[macro_export] -macro_rules! widgets { - ( - $name:ident; - $( - $widget:ident: - $( - [$mode:pat_param] - => - )? - $struc:ident, - )+ - [popups]: { - $( - $(#[$docs:meta])* - $pwidget:ident: - $( - [$pmode:pat_param] - => - )? - $pstruc:ident, - )+ - } - ) => { - #[derive(Default)] - pub struct $name { - $( - pub $widget: $struc, - )+ - $( - $(#[$docs])* - pub $pwidget: $pstruc, - )+ - } - - impl $name { - fn draw_popups(&mut self, ctx: &$crate::app::Context, f: &mut ratatui::Frame) { - match ctx.mode { - $( - $(#[$docs])* - $($pmode => self.$pwidget.draw(f, ctx, f.area()),)? - )+ - _ => {} - } - - } - - fn get_help(&self, mode: &$crate::app::Mode) -> Option> { - match mode { - $( - $($mode => $struc::get_help(),)? - )+ - $( - $(#[$docs])* - $($pmode => $pstruc::get_help(),)? - )+ - _ => None, - } - } - - fn handle_event(&mut self, ctx: &mut $crate::app::Context, evt: &crossterm::event::Event) { - match ctx.mode { - $( - $($mode => self.$widget.handle_event(ctx, evt),)? - )+ - $( - $(#[$docs])* - $($pmode => self.$pwidget.handle_event(ctx, evt),)? - )+ - _ => {} - }; - } - } - } -} - -#[macro_export] -macro_rules! cats { - ( - $( - $cats:expr => {$($idx:expr => ($icon:expr, $disp:expr, $conf:expr, $col:tt$(.$colext:tt)*);)+} - )+ - ) => {{ - let v = vec![ - $( - $crate::widget::category::CatStruct { - name: $cats.to_string(), - entries: vec![$($crate::widget::category::CatEntry::new( - $disp, - $conf, - $idx, - $icon, - |theme: &$crate::theme::Theme| {theme.$col$(.$colext)*}, - ), - )+], - },)+ - ]; - v - }} -} - -#[macro_export] -macro_rules! style { - ( - $($method:ident$(:$value:expr)?),* $(,)? - ) => {{ - #[allow(unused_imports)] - use ratatui::style::Stylize; - - let style = ratatui::style::Style::new() - $(.$method($($value)?))?; - style - }}; -} - -#[macro_export] -macro_rules! title { - // Single input - ($arg:expr) => {{ - let res = format!("{}", $arg); - res - }}; - - // format-like - ($($arg:expr),*$(,)?) => {{ - let res = format!("{}", format!($($arg),*)); - res - }}; - - // vec-like - ($($arg:expr);*$(;)?) => {{ - let res = vec![ - $($arg,)* - ]; - res - }}; -} - -#[macro_export] -macro_rules! collection { - // map-like - ($($k:expr => $v:expr),* $(,)?) => {{ - core::convert::From::from([$(($k, $v),)*]) - }}; - // set-like - ($($v:expr),* $(,)?) => {{ - core::convert::From::from([$($v,)*]) - }}; -} - -#[macro_export] -macro_rules! raw { - ( - $text:expr - ) => {{ - let raw = Text::raw($text); - raw - }}; -} - -#[macro_export] -macro_rules! cond_vec { - ($($cond:expr => $x:expr),+ $(,)?) => {{ - let v = vec![$(($cond, $x)),*].iter().filter_map(|(c, x)| { - if *c { Some(x.to_owned()) } else { None } - }).collect::>(); - v - }}; - ($cond:expr ; $x:expr) => {{ - let v = $cond - .iter() - .zip($x) - .filter_map(|(c, val)| if *c { Some(val.to_owned()) } else { None }) - .collect::>(); - v - }}; -} - -#[macro_export] -macro_rules! sel { - ( - $text:expr - ) => {{ - let raw = Selector::parse($text).map_err(|e| e.to_string()); - raw - }}; -} diff --git a/src/main.rs b/src/main.rs index 77c4655..c1152d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,89 +1,23 @@ -use std::{error::Error, io::stdout}; - use app::App; -use config::{AppConfig, ConfigManager}; -use ratatui::{backend::CrosstermBackend, Terminal}; -use sync::AppSync; - -#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] -use ratatui::termion::raw::IntoRawMode; - -pub mod app; -pub mod client; -pub mod clip; -pub mod config; -pub mod macros; -pub mod results; -pub mod source; -pub mod sync; -pub mod theme; -pub mod util; -pub mod widget; - -struct Args { - config_path: Option, -} - -fn parse_args() -> Result> { - use lexopt::prelude::*; - - let mut config_path = None; - let mut parser = lexopt::Parser::from_env(); - while let Some(arg) = parser.next()? { - match arg { - Short('c') | Long("config") => { - config_path = Some(shellexpand::full(&parser.value()?.string()?)?.to_string()); - } - Short('v') | Short('V') | Long("version") => { - println!("nyaa v{}", env!("CARGO_PKG_VERSION")); - std::process::exit(0); - } - Long("help") => { - println!("Usage: nyaa [-v|-V|--version] [-c|--config=/path/to/config/folder]"); - std::process::exit(0); - } - _ => return Err(arg.unexpected().into()), - } - } - Ok(Args { config_path }) -} +mod action; +mod app; +mod cli; +mod clients; +mod components; +mod config; +mod errors; +mod keys; +mod sources; +mod themes; +mod tui; #[tokio::main()] async fn main() -> Result<(), Box> { - let default_panic = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - // Try to reset terminal on panic - let _ = util::term::reset_terminal(); - default_panic(info); - std::process::exit(1); - })); - - let args = parse_args()?; - util::term::setup_terminal()?; - - #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] - let backend = CrosstermBackend::new(stdout()); - #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] - let backend = CrosstermBackend::new(stdout().into_raw_mode()?); - - let mut terminal = Terminal::new(backend)?; - - let mut app = App::default(); - let config = match args.config_path { - Some(path) => AppConfig::from_path(path), - None => AppConfig::new(), - }?; - let sync = AppSync::new(config.path()); - - app.run_app::<_, _, AppConfig, false>(&mut terminal, sync, config) - .await?; - - util::term::reset_terminal()?; - terminal.show_cursor()?; + let args = cli::read_args()?; - #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] - drop(terminal); // Drop terminal to leave raw mode and alternate screen + let mut app = App::new(args)?; + app.run().await?; std::process::exit(0); } diff --git a/src/results.rs b/src/results.rs deleted file mode 100644 index 4d93fbf..0000000 --- a/src/results.rs +++ /dev/null @@ -1,213 +0,0 @@ -use ratatui::{ - layout::{Alignment, Constraint}, - style::{Style, Stylize}, - text::{Span, Text}, - widgets::Row, -}; - -use crate::{source::Item, sync::SearchQuery, widget::sort::SortDir}; - -#[derive(Clone, Default)] -pub struct Results { - pub search: SearchQuery, - pub response: ResultResponse, - pub table: ResultTable, -} - -impl Results { - pub fn new(search: SearchQuery, response: ResultResponse, table: ResultTable) -> Self { - Self { - search, - response, - table, - } - } -} - -#[derive(Default, Clone)] -pub struct ResultResponse { - pub items: Vec, - pub last_page: usize, - pub total_results: usize, -} - -pub struct ResultHeader { - cols: Vec>, -} - -pub enum ResultColumn { - Normal(String, Constraint), - - // Sortable columns must have a known, fixed width - Sorted(String, u16, S), -} - -impl ResultHeader { - pub fn new(cols: T) -> Self - where - T: IntoIterator, - T::Item: Into>, - { - Self { - cols: cols.into_iter().map(Into::into).collect(), - } - } - - pub fn get_row(&self, dir: SortDir, sort_by: S) -> ResultRow { - ResultRow::new( - self.cols - .iter() - .map(|c| c.get_render(dir, sort_by)) - .collect::>(), - ) - } - - pub fn get_binding(&self) -> Vec { - self.cols - .iter() - .map(|c| match c { - ResultColumn::Normal(_, c) => *c, - ResultColumn::Sorted(_, l, _) => Constraint::Length(*l), - }) - .collect() - } -} - -impl ResultColumn { - pub fn get_render(&self, dir: SortDir, sort_by: S) -> String { - match self { - Self::Sorted(name, len, s) => { - let mut name = format!("{:^width$}", name, width = *len as usize); - if sort_by.eq(s) { - if let Some(idx) = name.rfind(|c: char| !c.is_whitespace()) { - if idx + 2 < name.len() { - name.replace_range( - name.char_indices() - .nth(idx + 2) - .map(|(pos, ch)| (pos..pos + ch.len_utf8())) - .unwrap(), - match dir { - SortDir::Asc => "▲", - SortDir::Desc => "▼", - }, - ); - } - } - } - name - } - Self::Normal(name, _) => name.to_owned(), - } - } -} - -#[derive(Default, Clone)] -pub struct ResultTable { - pub headers: ResultRow, - pub rows: Vec, - pub binding: Vec, -} - -#[derive(Clone)] -pub struct ResultCell { - pub content: String, - pub style: Style, - pub alignment: Alignment, -} - -impl<'a> From for Row<'a> { - fn from(val: ResultRow) -> Self { - Row::new(val.cells) - } -} - -impl<'a> From for Text<'a> { - fn from(val: ResultCell) -> Self { - Text::raw(val.content) - .style(val.style) - .alignment(val.alignment) - } -} - -impl<'a> From> for ResultCell { - fn from(value: Span<'a>) -> Self { - Self { - content: value.content.to_string(), - style: value.style, - alignment: Alignment::Left, - } - } -} - -impl From for ResultCell { - fn from(value: String) -> Self { - Self { - content: value, - style: Style::default(), - alignment: Alignment::Left, - } - } -} - -#[derive(Default, Clone)] -pub struct ResultRow { - pub cells: Vec, - pub style: Style, -} - -impl<'a> Stylize<'a, ResultRow> for ResultRow { - fn bg>(self, color: S) -> Self { - let mut newself = self; - newself.style = newself.style.bg(color.into()); - newself - } - - fn fg>(self, color: S) -> Self { - let mut newself = self; - newself.style = newself.style.fg(color.into()); - newself - } - - fn reset(self) -> Self { - let mut newself = self; - newself.style = newself.style.reset(); - newself - } - - fn add_modifier(self, modifier: ratatui::prelude::Modifier) -> Self { - let mut newself = self; - newself.style = newself.style.add_modifier(modifier); - newself - } - - fn remove_modifier(self, modifier: ratatui::prelude::Modifier) -> Self { - let mut newself = self; - newself.style = newself.style.remove_modifier(modifier); - newself - } -} - -impl ResultRow { - pub fn new(cells: T) -> Self - where - T: IntoIterator, - T::Item: Into, - { - Self { - cells: cells.into_iter().map(Into::into).collect(), - style: Style::default(), - } - } - - pub fn aligned(&mut self, align: A) -> Self - where - A: IntoIterator, - A::Item: Into, - { - self.cells - .iter_mut() - .zip(align) - .for_each(|(c, a)| c.alignment = a.into()); - self.to_owned() - } -} diff --git a/src/source.rs b/src/source.rs deleted file mode 100644 index 63ee761..0000000 --- a/src/source.rs +++ /dev/null @@ -1,381 +0,0 @@ -use std::{collections::HashMap, error::Error, sync::Arc, time::Duration}; - -use nyaa_html::NyaaTheme; -use reqwest::{cookie::Jar, Proxy}; -use serde::{Deserialize, Serialize}; -use strum::{Display, VariantArray}; -use sukebei_nyaa::SukebeiTheme; -use torrent_galaxy::TgxTheme; - -use crate::{ - app::{Context, LoadType, Widgets}, - config::Config, - results::{ResultResponse, ResultTable, Results}, - sync::SearchQuery, - theme::Theme, - util::conv::add_protocol, - widget::{ - category::{CatEntry, CatIcon, CatStruct}, - sort::SelectedSort, - }, -}; - -use self::{ - nyaa_html::{NyaaConfig, NyaaHtmlSource}, - sukebei_nyaa::{SukebeiHtmlSource, SukebeiNyaaConfig}, - torrent_galaxy::{TgxConfig, TorrentGalaxyHtmlSource}, -}; - -#[cfg(feature = "captcha")] -use ratatui_image::protocol::StatefulProtocol; - -pub mod nyaa_html; -pub mod nyaa_rss; -pub mod sukebei_nyaa; -pub mod torrent_galaxy; - -#[derive(Clone)] -pub enum SourceResults { - Results(Results), - #[cfg(feature = "captcha")] - Captcha(Box), -} - -#[derive(Clone)] -pub enum SourceResponse { - Results(ResultResponse), - #[cfg(feature = "captcha")] - Captcha(Box), -} - -#[derive(Serialize, Deserialize, Clone, Copy, Default)] -pub struct SourceTheme { - #[serde(default)] - pub nyaa: NyaaTheme, - #[serde(default)] - pub sukebei: SukebeiTheme, - #[serde(default, rename = "torrentgalaxy")] - pub tgx: TgxTheme, -} - -#[derive(Serialize, Deserialize, Clone, Default)] -#[serde(default)] -pub struct SourceConfig { - pub nyaa: Option, - #[serde(rename = "sukebei")] - pub sukebei: Option, - #[serde(rename = "torrentgalaxy")] - pub tgx: Option, -} - -pub struct SourceExtraConfig { - pub date_format: Option, - pub relative_date: Option, - pub relative_date_short: Option, -} - -impl From for SourceExtraConfig { - fn from(c: Config) -> Self { - SourceExtraConfig { - date_format: c.date_format, - relative_date: c.relative_date, - relative_date_short: c.relative_date_short, - } - } -} - -#[derive(Clone)] -pub struct SourceInfo { - pub cats: Vec, - pub filters: Vec, - pub sorts: Vec, -} - -impl SourceInfo { - pub fn get_major_minor(&self, id: usize) -> (usize, usize) { - for (major, cat) in self.cats.iter().enumerate() { - if let Some((minor, _)) = cat.entries.iter().enumerate().find(|(_, ent)| ent.id == id) { - return (major, minor); - } - } - (0, 0) - } - pub fn entry_from_cfg(&self, s: &str) -> CatEntry { - for cat in self.cats.iter() { - if let Some(ent) = cat.entries.iter().find(|ent| ent.cfg == s) { - return ent.clone(); - } - } - self.cats[0].entries[0].clone() - // self.cats[0].entries[0].clone() - } - - pub fn entry_from_str(self, s: &str) -> CatEntry { - let split: Vec<&str> = s.split('_').collect(); - let high = split.first().unwrap_or(&"1").parse().unwrap_or(1); - let low = split.last().unwrap_or(&"0").parse().unwrap_or(0); - let id = high * 10 + low; - self.entry_from_id(id) - } - - pub fn entry_from_id(self, id: usize) -> CatEntry { - for cat in self.cats.iter() { - if let Some(ent) = cat.entries.iter().find(|ent| ent.id == id) { - return ent.clone(); - } - } - self.cats[0].entries[0].clone() - } -} - -pub fn request_client( - jar: &Arc, - timeout: u64, - proxy_url: Option, -) -> Result> { - let mut client = reqwest::Client::builder() - .gzip(true) - .cookie_provider(jar.clone()) - .timeout(Duration::from_secs(timeout)); - if let Some(proxy_url) = proxy_url { - client = client.proxy(Proxy::all( - add_protocol(proxy_url, false).map_err(|e| e.to_string())?, - )?); - } - Ok(client.build()?) -} - -#[derive(Default, Clone, Copy)] -pub enum ItemType { - #[default] - None, - Trusted, - Remake, -} - -#[derive(Clone, Default)] -pub struct Item { - pub id: String, - pub date: String, - pub seeders: u32, - pub leechers: u32, - pub downloads: u32, - pub size: String, - pub bytes: usize, - pub title: String, - pub torrent_link: String, - pub magnet_link: String, - pub post_link: String, - pub file_name: String, - pub category: usize, - pub icon: CatIcon, - pub item_type: ItemType, - pub extra: HashMap, -} - -#[derive(Serialize, Deserialize, Display, Clone, Copy, VariantArray, PartialEq, Eq)] -pub enum Sources { - #[strum(serialize = "Nyaa")] - Nyaa = 0, - #[strum(serialize = "Sukebei")] - SukebeiNyaa = 1, - #[strum(serialize = "TorrentGalaxy")] - TorrentGalaxy = 2, -} - -pub trait Source { - fn search( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> impl std::future::Future>> + Send; - fn sort( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> impl std::future::Future>> + Send; - fn filter( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> impl std::future::Future>> + Send; - fn categorize( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> impl std::future::Future>> + Send; - fn solve( - solution: String, - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> impl std::future::Future>> + Send; - fn info() -> SourceInfo; - fn load_config(config: &mut SourceConfig); - - fn default_category(config: &SourceConfig) -> usize; - fn default_sort(config: &SourceConfig) -> SelectedSort; - fn default_filter(config: &SourceConfig) -> usize; - fn default_search(config: &SourceConfig) -> String; - - fn format_table( - items: &[Item], - sort: &SearchQuery, - config: &SourceConfig, - theme: &Theme, - ) -> ResultTable; -} - -impl Sources { - pub async fn load( - &self, - load_type: LoadType, - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> Result> { - match self { - Sources::Nyaa => match load_type { - LoadType::Searching | LoadType::Sourcing => { - NyaaHtmlSource::search(client, search, config, extra).await - } - LoadType::Sorting => NyaaHtmlSource::sort(client, search, config, extra).await, - LoadType::Filtering => NyaaHtmlSource::filter(client, search, config, extra).await, - LoadType::Categorizing => { - NyaaHtmlSource::categorize(client, search, config, extra).await - } - LoadType::SolvingCaptcha(solution) => { - NyaaHtmlSource::solve(solution, client, search, config, extra).await - } - LoadType::Downloading | LoadType::Batching => unreachable!(), - }, - Sources::SukebeiNyaa => match load_type { - LoadType::Searching | LoadType::Sourcing => { - SukebeiHtmlSource::search(client, search, config, extra).await - } - LoadType::Sorting => SukebeiHtmlSource::sort(client, search, config, extra).await, - LoadType::Filtering => { - SukebeiHtmlSource::filter(client, search, config, extra).await - } - LoadType::Categorizing => { - SukebeiHtmlSource::categorize(client, search, config, extra).await - } - LoadType::SolvingCaptcha(solution) => { - SukebeiHtmlSource::solve(solution, client, search, config, extra).await - } - LoadType::Downloading | LoadType::Batching => unreachable!(), - }, - Sources::TorrentGalaxy => match load_type { - LoadType::Searching | LoadType::Sourcing => { - TorrentGalaxyHtmlSource::search(client, search, config, extra).await - } - LoadType::Sorting => { - TorrentGalaxyHtmlSource::sort(client, search, config, extra).await - } - LoadType::Filtering => { - TorrentGalaxyHtmlSource::filter(client, search, config, extra).await - } - LoadType::Categorizing => { - TorrentGalaxyHtmlSource::categorize(client, search, config, extra).await - } - LoadType::SolvingCaptcha(solution) => { - TorrentGalaxyHtmlSource::solve(solution, client, search, config, extra).await - } - LoadType::Downloading | LoadType::Batching => unreachable!(), - }, - } - } - - pub fn apply(self, ctx: &mut Context, w: &mut Widgets) { - ctx.src_info = self.info(); - w.category.selected = self.default_category(&ctx.config.sources); - - let (major, minor) = ctx.src_info.get_major_minor(w.category.selected); - w.category.table.select(major + minor + 1); - w.category.major = major; - w.category.minor = minor; - - w.sort.selected = self.default_sort(&ctx.config.sources); - w.sort.table.select(w.sort.selected.sort); - w.filter.selected = self.default_filter(&ctx.config.sources); - w.filter.table.select(w.filter.selected); - - w.search.input.input = self.default_search(&ctx.config.sources); - w.search - .input - .set_cursor(w.search.input.input.chars().count()); - - // Go back to first page when changing source - ctx.page = 1; - } - - pub fn info(self) -> SourceInfo { - match self { - Sources::Nyaa => NyaaHtmlSource::info(), - Sources::SukebeiNyaa => SukebeiHtmlSource::info(), - Sources::TorrentGalaxy => TorrentGalaxyHtmlSource::info(), - } - } - - pub fn load_config(self, config: &mut SourceConfig) { - match self { - Sources::Nyaa => NyaaHtmlSource::load_config(config), - Sources::SukebeiNyaa => SukebeiHtmlSource::load_config(config), - Sources::TorrentGalaxy => TorrentGalaxyHtmlSource::load_config(config), - }; - } - - pub fn default_category(self, config: &SourceConfig) -> usize { - match self { - Sources::Nyaa => NyaaHtmlSource::default_category(config), - Sources::SukebeiNyaa => SukebeiHtmlSource::default_category(config), - Sources::TorrentGalaxy => TorrentGalaxyHtmlSource::default_category(config), - } - } - - pub fn default_sort(self, config: &SourceConfig) -> SelectedSort { - match self { - Sources::Nyaa => NyaaHtmlSource::default_sort(config), - Sources::SukebeiNyaa => SukebeiHtmlSource::default_sort(config), - Sources::TorrentGalaxy => TorrentGalaxyHtmlSource::default_sort(config), - } - } - - pub fn default_filter(self, config: &SourceConfig) -> usize { - match self { - Sources::Nyaa => NyaaHtmlSource::default_filter(config), - Sources::SukebeiNyaa => SukebeiHtmlSource::default_filter(config), - Sources::TorrentGalaxy => TorrentGalaxyHtmlSource::default_filter(config), - } - } - - pub fn default_search(self, config: &SourceConfig) -> String { - match self { - Sources::Nyaa => NyaaHtmlSource::default_search(config), - Sources::SukebeiNyaa => SukebeiHtmlSource::default_search(config), - Sources::TorrentGalaxy => TorrentGalaxyHtmlSource::default_search(config), - } - } - - pub fn format_table( - self, - items: &[Item], - search: &SearchQuery, - config: &SourceConfig, - theme: &Theme, - ) -> ResultTable { - match self { - Sources::Nyaa => NyaaHtmlSource::format_table(items, search, config, theme), - Sources::SukebeiNyaa => SukebeiHtmlSource::format_table(items, search, config, theme), - Sources::TorrentGalaxy => { - TorrentGalaxyHtmlSource::format_table(items, search, config, theme) - } - } - } -} diff --git a/src/source/nyaa_html.rs b/src/source/nyaa_html.rs deleted file mode 100644 index 2fbd87b..0000000 --- a/src/source/nyaa_html.rs +++ /dev/null @@ -1,584 +0,0 @@ -use std::fmt::Write; -use std::{cmp::max, error::Error, time::Duration}; - -use chrono::{DateTime, Local, NaiveDateTime, TimeZone}; -use ratatui::{ - layout::{Alignment, Constraint}, - style::{Color, Stylize as _}, -}; -use reqwest::StatusCode; -use scraper::{Html, Selector}; -use serde::{Deserialize, Serialize}; -use strum::{Display, FromRepr, VariantArray}; -use urlencoding::encode; - -use crate::util; -use crate::{ - cats, cond_vec, - results::{ResultColumn, ResultHeader, ResultResponse, ResultRow, ResultTable}, - sel, - sync::SearchQuery, - theme::Theme, - util::{ - colors::color_to_tui, - conv::{shorten_number, to_bytes}, - html::{as_type, attr, inner}, - }, - widget::sort::{SelectedSort, SortDir}, -}; - -use super::{ - add_protocol, nyaa_rss, Item, ItemType, Source, SourceConfig, SourceExtraConfig, SourceInfo, - SourceResponse, -}; - -#[derive(Serialize, Deserialize, Clone, Copy, Default)] -#[serde(default)] -pub struct NyaaTheme { - #[serde(rename = "categories")] - cat: NyaaCategoryTheme, -} - -#[derive(Serialize, Deserialize, Clone, Copy)] -#[serde(default)] -pub struct NyaaCategoryTheme { - #[serde(with = "color_to_tui")] - pub anime_english_translated: Color, - #[serde(with = "color_to_tui")] - pub anime_non_english_translated: Color, - #[serde(with = "color_to_tui")] - pub anime_raw: Color, - #[serde(with = "color_to_tui")] - pub anime_music_video: Color, - #[serde(with = "color_to_tui")] - pub audio_lossless: Color, - #[serde(with = "color_to_tui")] - pub audio_lossy: Color, - #[serde(with = "color_to_tui")] - pub literature_english_translated: Color, - #[serde(with = "color_to_tui")] - pub literature_non_english_translated: Color, - #[serde(with = "color_to_tui")] - pub literature_raw: Color, - #[serde(with = "color_to_tui")] - pub live_english_translated: Color, - #[serde(with = "color_to_tui")] - pub live_non_english_translated: Color, - #[serde(with = "color_to_tui")] - pub live_idol_promo_video: Color, - #[serde(with = "color_to_tui")] - pub live_raw: Color, - #[serde(with = "color_to_tui")] - pub picture_graphics: Color, - #[serde(with = "color_to_tui")] - pub picture_photos: Color, - #[serde(with = "color_to_tui")] - pub software_applications: Color, - #[serde(with = "color_to_tui")] - pub software_games: Color, -} - -impl Default for NyaaCategoryTheme { - fn default() -> Self { - use Color::*; - Self { - anime_english_translated: LightMagenta, - anime_non_english_translated: LightGreen, - anime_raw: Gray, - anime_music_video: Magenta, - audio_lossless: Red, - audio_lossy: Yellow, - literature_english_translated: LightGreen, - literature_non_english_translated: Yellow, - literature_raw: Gray, - live_english_translated: Yellow, - live_non_english_translated: LightCyan, - live_idol_promo_video: LightYellow, - live_raw: Gray, - picture_graphics: LightMagenta, - picture_photos: Magenta, - software_applications: Blue, - software_games: LightBlue, - } - } -} - -#[derive(Serialize, Deserialize, Clone)] -#[serde(default)] -pub struct NyaaConfig { - pub base_url: String, - pub default_sort: NyaaSort, - pub default_sort_dir: SortDir, - pub default_filter: NyaaFilter, - pub default_category: String, - pub default_search: String, - pub rss: bool, - pub timeout: Option, - pub columns: Option, -} - -#[derive(Clone, Copy, Serialize, Deserialize, Default)] -pub struct NyaaColumns { - category: Option, - title: Option, - size: Option, - date: Option, - seeders: Option, - leechers: Option, - downloads: Option, -} - -impl NyaaColumns { - fn array(self) -> [bool; 7] { - [ - self.category.unwrap_or(true), - self.title.unwrap_or(true), - self.size.unwrap_or(true), - self.date.unwrap_or(true), - self.seeders.unwrap_or(true), - self.leechers.unwrap_or(true), - self.downloads.unwrap_or(true), - ] - } -} - -impl Default for NyaaConfig { - fn default() -> Self { - Self { - base_url: "https://nyaa.si/".to_owned(), - default_sort: NyaaSort::Date, - default_sort_dir: SortDir::Desc, - default_filter: NyaaFilter::NoFilter, - default_category: "AllCategories".to_owned(), - default_search: Default::default(), - rss: false, - timeout: None, - columns: None, - } - } -} - -#[derive(Serialize, Deserialize, Display, Clone, Copy, VariantArray, PartialEq, Eq, FromRepr)] -#[repr(usize)] -pub enum NyaaSort { - #[strum(serialize = "Date")] - Date = 0, - #[strum(serialize = "Downloads")] - Downloads = 1, - #[strum(serialize = "Seeders")] - Seeders = 2, - #[strum(serialize = "Leechers")] - Leechers = 3, - #[strum(serialize = "Size")] - Size = 4, -} - -impl NyaaSort { - pub fn to_url(self) -> String { - match self { - NyaaSort::Date => "id".to_owned(), - NyaaSort::Downloads => "downloads".to_owned(), - NyaaSort::Seeders => "seeders".to_owned(), - NyaaSort::Leechers => "leechers".to_owned(), - NyaaSort::Size => "size".to_owned(), - } - } -} - -#[derive(Serialize, Deserialize, Display, Clone, Copy, VariantArray, PartialEq, Eq, FromRepr)] -pub enum NyaaFilter { - #[allow(clippy::enum_variant_names)] - #[strum(serialize = "No Filter")] - NoFilter = 0, - #[strum(serialize = "No Remakes")] - NoRemakes = 1, - #[strum(serialize = "Trusted Only")] - TrustedOnly = 2, - #[strum(serialize = "Batches")] - Batches = 3, -} - -pub struct NyaaHtmlSource; - -pub fn nyaa_table( - items: Vec, - theme: &Theme, - sel_sort: &SelectedSort, - columns: &Option, -) -> ResultTable { - let raw_date_width = items.iter().map(|i| i.date.len()).max().unwrap_or_default() as u16; - let date_width = max(raw_date_width, 6); - - let header = ResultHeader::new([ - ResultColumn::Normal("Cat".to_owned(), Constraint::Length(3)), - ResultColumn::Normal("Name".to_owned(), Constraint::Min(3)), - ResultColumn::Sorted("Size".to_owned(), 9, NyaaSort::Size as u32), - ResultColumn::Sorted("Date".to_owned(), date_width, NyaaSort::Date as u32), - ResultColumn::Sorted("".to_owned(), 4, NyaaSort::Seeders as u32), - ResultColumn::Sorted("".to_owned(), 4, NyaaSort::Leechers as u32), - ResultColumn::Sorted("".to_owned(), 5, NyaaSort::Downloads as u32), - ]); - let mut binding = header.get_binding(); - let align = [ - Alignment::Left, - Alignment::Left, - Alignment::Right, - Alignment::Left, - Alignment::Right, - Alignment::Right, - Alignment::Left, - ]; - let mut rows: Vec = items - .into_iter() - .map(|item| { - ResultRow::new([ - item.icon.label.fg((item.icon.color)(theme)), - item.title.fg(match item.item_type { - ItemType::Trusted => theme.success, - ItemType::Remake => theme.error, - ItemType::None => theme.fg, - }), - item.size.fg(theme.fg), - item.date.fg(theme.fg), - item.seeders.to_string().fg(theme.success), - item.leechers.to_string().fg(theme.error), - shorten_number(item.downloads).fg(theme.fg), - ]) - .aligned(align) - .fg(theme.fg) - }) - .collect(); - - let mut headers = header.get_row(sel_sort.dir, sel_sort.sort as u32); - if let Some(columns) = columns { - let cols = columns.array(); - - headers.cells = cond_vec!(cols ; headers.cells); - rows = rows - .clone() - .into_iter() - .map(|mut r| { - r.cells = cond_vec!(cols ; r.cells.to_owned()); - r - }) - .collect::>(); - binding = cond_vec!(cols ; binding); - } - ResultTable { - headers, - rows, - binding, - } -} - -impl Source for NyaaHtmlSource { - async fn search( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> Result> { - let nyaa = config.nyaa.to_owned().unwrap_or_default(); - if nyaa.rss { - return nyaa_rss::search_rss::( - nyaa.base_url, - nyaa.timeout, - client, - search, - extra, - ) - .await; - } - let cat = search.category; - let filter = search.filter; - let page = search.page; - let user = search.user.to_owned().unwrap_or_default(); - let sort = NyaaSort::from_repr(search.sort.sort) - .unwrap_or(NyaaSort::Date) - .to_url(); - - let base_url = add_protocol(nyaa.base_url, true)?; - let mut url = base_url.clone(); - // let base_url = add_protocol(ctx.config.base_url.clone(), true); - let (high, low) = (cat / 10, cat % 10); - let query = encode(&search.query); - let dir = search.sort.dir.to_url(); - url.set_query(Some(&format!( - "q={}&c={}_{}&f={}&p={}&s={}&o={}&u={}", - query, high, low, filter, page, sort, dir, user - ))); - - let mut request = client.get(url.to_owned()); - if let Some(timeout) = nyaa.timeout { - request = request.timeout(Duration::from_secs(timeout)); - } - let response = request.send().await?; - if response.status() != StatusCode::OK { - // Throw error if response code is not OK - let code = response.status().as_u16(); - return Err(format!("{}\nInvalid response code: {}", url, code).into()); - } - let content = response.bytes().await?; - let doc = Html::parse_document(std::str::from_utf8(&content[..])?); - - // let item_sel = &Selector::parse("table.torrent-list > tbody > tr")?; - let item_sel = &sel!("table.torrent-list > tbody > tr")?; - let icon_sel = &sel!("td:first-of-type > a")?; - let title_sel = &sel!("td:nth-of-type(2) > a:last-of-type")?; - let torrent_sel = &sel!("td:nth-of-type(3) > a:nth-of-type(1)")?; - let magnet_sel = &sel!("td:nth-of-type(3) > a:nth-of-type(2)")?; - let size_sel = &sel!("td:nth-of-type(4)")?; - let date_sel = &sel!("td:nth-of-type(5)").unwrap(); - let seed_sel = &sel!("td:nth-of-type(6)")?; - let leech_sel = &sel!("td:nth-of-type(7)")?; - let dl_sel = &sel!("td:nth-of-type(8)")?; - let pagination_sel = &sel!(".pagination-page-info")?; - - let mut last_page = 100; - let mut total_results = 7500; - // For searches, pagination has a description of total results found - if let Some(pagination) = doc.select(pagination_sel).next() { - // 6th word in pagination description contains total number of results - if let Some(num_results_str) = pagination.inner_html().split(' ').nth(5) { - if let Ok(num_results) = num_results_str.parse::() { - last_page = (num_results + 74) / 75; - total_results = num_results; - } - } - } - - let items: Vec = doc - .select(item_sel) - .filter_map(|e| { - let cat_str = attr(e, icon_sel, "href"); - let cat_str = cat_str.split('=').last().unwrap_or(""); - let cat = Self::info().entry_from_str(cat_str); - let category = cat.id; - let icon = cat.icon.clone(); - - let torrent = attr(e, torrent_sel, "href"); - let id = torrent - .split('/') - .last()? - .split('.') - .next()? - .parse::() - .ok()?; - let id = format!("nyaa-{}", id); - let file_name = format!("{}.torrent", id); - - let size = inner(e, size_sel, "0 bytes") - .replace('i', "") - .replace("Bytes", "B"); - let bytes = to_bytes(&size); - - const DEFAULT_DATE_FORMAT: &str = "%Y-%m-%d %H:%M"; - let date_raw = inner(e, date_sel, ""); - let naive = NaiveDateTime::parse_from_str(&date_raw, DEFAULT_DATE_FORMAT) - .unwrap_or_default(); - let date_time: DateTime = Local.from_utc_datetime(&naive); - let date_format = extra.date_format.as_deref().unwrap_or(DEFAULT_DATE_FORMAT); - - let date = if extra.relative_date.unwrap_or(false) { - util::conv::to_relative_date( - date_time, - extra.relative_date_short.unwrap_or(false), - ) - } else { - let mut newstr = String::new(); - if write!(newstr, "{}", date_time.format(date_format)).is_err() { - newstr = format!("Invalid format string: `{}`", date_format); - } - newstr - }; - - let seeders = as_type(inner(e, seed_sel, "0")).unwrap_or_default(); - let leechers = as_type(inner(e, leech_sel, "0")).unwrap_or_default(); - let downloads = as_type(inner(e, dl_sel, "0")).unwrap_or_default(); - let torrent_link = base_url - .join(&torrent) - .map(Into::into) - .unwrap_or("null".to_owned()); - let post_link = base_url - .join(&attr(e, title_sel, "href")) - .map(Into::into) - .unwrap_or("null".to_owned()); - - let trusted = e.value().classes().any(|e| e == "success"); - let remake = e.value().classes().any(|e| e == "danger"); - let item_type = match (trusted, remake) { - (true, _) => ItemType::Trusted, - (_, true) => ItemType::Remake, - _ => ItemType::None, - }; - - Some(Item { - id, - date, - seeders, - leechers, - downloads, - size, - bytes, - title: attr(e, title_sel, "title"), - torrent_link, - magnet_link: attr(e, magnet_sel, "href"), - post_link, - file_name: file_name.to_owned(), - category, - icon, - item_type, - ..Default::default() - }) - }) - .collect(); - - Ok(SourceResponse::Results(ResultResponse { - items, - total_results, - last_page, - })) - } - async fn sort( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> Result> { - let nyaa = config.nyaa.to_owned().unwrap_or_default(); - let sort = search.sort; - let mut res = NyaaHtmlSource::search(client, search, config, extra).await; - - if nyaa.rss { - if let Ok(SourceResponse::Results(res)) = &mut res { - nyaa_rss::sort_items(&mut res.items, sort); - } - } - res - } - async fn filter( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> Result> { - NyaaHtmlSource::search(client, search, config, extra).await - } - async fn categorize( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> Result> { - NyaaHtmlSource::search(client, search, config, extra).await - } - async fn solve( - _solution: String, - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> Result> { - NyaaHtmlSource::search(client, search, config, extra).await - } - - fn info() -> SourceInfo { - let cats = cats! { - "All Categories" => { - 0 => ("---", "All Categories", "AllCategories", fg); - } - "Anime" => { - 10 => ("Ani", "All Anime", "AllAnime", fg); - 12 => ("Sub", "English Translated", "AnimeEnglishTranslated", source.nyaa.cat.anime_english_translated); - 13 => ("Sub", "Non-English Translated", "AnimeNonEnglishTranslated", source.nyaa.cat.anime_non_english_translated); - 14 => ("Raw", "Raw", "AnimeRaw", source.nyaa.cat.anime_raw); - 11 => ("AMV", "Anime Music Video", "AnimeMusicVideo", source.nyaa.cat.anime_music_video); - } - "Audio" => { - 20 => ("Aud", "All Audio", "AllAudio", fg); - 21 => ("Aud", "Lossless", "AudioLossless", source.nyaa.cat.audio_lossless); - 22 => ("Aud", "Lossy", "AudioLossy", source.nyaa.cat.audio_lossy); - } - "Literature" => { - 30 => ("Lit", "All Literature", "AllLiterature", fg); - 31 => ("Lit", "English Translated", "LitEnglishTranslated", source.nyaa.cat.literature_english_translated); - 32 => ("Lit", "Non-English Translated", "LitNonEnglishTranslated", source.nyaa.cat.literature_non_english_translated); - 33 => ("Lit", "Raw", "LitRaw", source.nyaa.cat.literature_raw); - } - "Live Action" => { - 40 => ("Liv", "All Live Action", "AllLiveAction", fg); - 41 => ("Liv", "English Translated", "LiveEnglishTranslated", source.nyaa.cat.live_english_translated); - 43 => ("Liv", "Non-English Translated", "LiveNonEnglishTranslated", source.nyaa.cat.live_non_english_translated); - 42 => ("Liv", "Idol/Promo Video", "LiveIdolPromoVideo", source.nyaa.cat.live_idol_promo_video); - 44 => ("Liv", "Raw", "LiveRaw", source.nyaa.cat.live_raw); - } - "Pictures" => { - 50 => ("Pic", "All Pictures", "AllPictures", fg); - 51 => ("Pic", "Graphics", "PicGraphics", source.nyaa.cat.picture_graphics); - 52 => ("Pic", "Photos", "PicPhotos", source.nyaa.cat.picture_photos); - } - "Software" => { - 60 => ("Sof", "All Software", "AllSoftware", fg); - 61 => ("Sof", "Applications", "SoftApplications", source.nyaa.cat.software_applications); - 62 => ("Sof", "Games", "SoftGames", source.nyaa.cat.software_games); - } - }; - SourceInfo { - cats, - filters: NyaaFilter::VARIANTS - .iter() - .map(ToString::to_string) - .collect(), - sorts: NyaaSort::VARIANTS.iter().map(ToString::to_string).collect(), - } - } - - fn load_config(config: &mut SourceConfig) { - if config.nyaa.is_none() { - config.nyaa = Some(NyaaConfig::default()); - } - } - - fn default_category(cfg: &SourceConfig) -> usize { - let default = cfg - .nyaa - .as_ref() - .map(|c| c.default_category.to_owned()) - .unwrap_or_default(); - Self::info().entry_from_cfg(&default).id - } - - fn default_sort(cfg: &SourceConfig) -> SelectedSort { - cfg.nyaa - .as_ref() - .map(|c| SelectedSort { - sort: c.default_sort as usize, - dir: c.default_sort_dir, - }) - .unwrap_or_default() - } - - fn default_filter(cfg: &SourceConfig) -> usize { - cfg.nyaa - .as_ref() - .map(|c| c.default_filter as usize) - .unwrap_or_default() - } - - fn default_search(cfg: &SourceConfig) -> String { - cfg.nyaa - .as_ref() - .map(|c| c.default_search.to_owned()) - .unwrap_or_default() - } - - fn format_table( - items: &[Item], - search: &SearchQuery, - config: &SourceConfig, - theme: &Theme, - ) -> ResultTable { - let nyaa = config.nyaa.to_owned().unwrap_or_default(); - nyaa_table(items.into(), theme, &search.sort, &nyaa.columns) - } -} diff --git a/src/source/nyaa_rss.rs b/src/source/nyaa_rss.rs deleted file mode 100644 index af87558..0000000 --- a/src/source/nyaa_rss.rs +++ /dev/null @@ -1,155 +0,0 @@ -use std::fmt::Write; -use std::{cmp::Ordering, collections::BTreeMap, error::Error, str::FromStr, time::Duration}; - -use chrono::{DateTime, Local}; -use reqwest::StatusCode; -use rss::{extension::Extension, Channel}; -use urlencoding::encode; - -use crate::{ - results::ResultResponse, - sync::SearchQuery, - util::{self, conv::to_bytes}, - widget::sort::{SelectedSort, SortDir}, -}; - -use super::{ - add_protocol, nyaa_html::NyaaSort, Item, ItemType, Source, SourceExtraConfig, SourceResponse, -}; - -type ExtensionMap = BTreeMap>; - -pub fn get_ext_value(ext_map: &ExtensionMap, key: &str) -> T { - ext_map - .get(key) - .and_then(|v| v.first()) - .and_then(|s| s.value()) - .and_then(|val| val.parse().ok()) - .unwrap_or_default() -} - -pub fn sort_items(items: &mut [Item], sort: SelectedSort) { - let f: fn(&Item, &Item) -> Ordering = match NyaaSort::from_repr(sort.sort) { - Some(NyaaSort::Downloads) => |a, b| b.downloads.cmp(&a.downloads), - Some(NyaaSort::Seeders) => |a, b| b.seeders.cmp(&a.seeders), - Some(NyaaSort::Leechers) => |a, b| b.leechers.cmp(&a.leechers), - Some(NyaaSort::Size) => |a, b| b.bytes.cmp(&a.bytes), - _ => |a, b| a.id.cmp(&b.id), - }; - items.sort_by(f); - if sort.dir == SortDir::Asc { - items.reverse(); - } -} - -pub async fn search_rss( - base_url: String, - timeout: Option, - client: &reqwest::Client, - search: &SearchQuery, - extra: &SourceExtraConfig, -) -> Result> { - let query = search.query.to_owned(); - let cat = search.category; - let filter = search.filter; - let user = search.user.to_owned().unwrap_or_default(); - let last_page = 1; - let (high, low) = (cat / 10, cat % 10); - let query = encode(&query); - let base_url = add_protocol(base_url, true)?; - - let mut url = base_url.clone(); - let query = format!( - "page=rss&f={}&c={}_{}&q={}&u={}&m", - filter, high, low, query, user - ); - url.set_query(Some(&query)); - - let mut request = client.get(url.to_owned()); - if let Some(timeout) = timeout { - request = request.timeout(Duration::from_secs(timeout)); - } - let response = request.send().await?; - let code = response.status().as_u16(); - if code != StatusCode::OK { - // Throw error if response code is not OK - return Err(format!("{}\nInvalid response code: {}", url, code).into()); - } - - let bytes = response.bytes().await?; - let channel = Channel::read_from(&bytes[..])?; - - let mut items: Vec = channel - .items - .iter() - .filter_map(|item| { - let ext = item.extensions().get("nyaa")?; - let guid = item.guid()?; - let post = guid.value.clone(); - let id = guid.value.rsplit('/').next().unwrap_or_default(); // Get nyaa id from guid url in format - // `https://nyaa.si/view/{id}` - let id_usize = id.parse::().ok()?; - let category_str = get_ext_value::(ext, "categoryId"); - let cat = S::info().entry_from_str(&category_str); - let category = cat.id; - let icon = cat.icon.clone(); - let size = get_ext_value::(ext, "size") - .replace('i', "") - .replace("Bytes", "B"); - let pub_date = item.pub_date().unwrap_or(""); - let date_time = DateTime::parse_from_rfc2822(pub_date).unwrap_or_default(); - let date_time = date_time.with_timezone(&Local); - let torrent_link = base_url - .join(&format!("/download/{}.torrent", id)) - .map(Into::into) - .unwrap_or("null".to_owned()); - let trusted = get_ext_value::(ext, "trusted").eq("Yes"); - let remake = get_ext_value::(ext, "remake").eq("Yes"); - let item_type = match (trusted, remake) { - (true, _) => ItemType::Trusted, - (_, true) => ItemType::Remake, - _ => ItemType::None, - }; - let date_format = extra - .date_format - .to_owned() - .unwrap_or("%Y-%m-%d %H:%M".to_owned()); - - let date = if extra.relative_date.unwrap_or(false) { - util::conv::to_relative_date(date_time, extra.relative_date_short.unwrap_or(false)) - } else { - let mut newstr = String::new(); - if write!(newstr, "{}", date_time.format(&date_format)).is_err() { - newstr = format!("Invalid format string: `{}`", date_format); - } - newstr - }; - - Some(Item { - id: format!("nyaa-{}", id_usize), - date, - seeders: get_ext_value(ext, "seeders"), - leechers: get_ext_value(ext, "leechers"), - downloads: get_ext_value(ext, "downloads"), - bytes: to_bytes(&size), - size, - title: item.title().unwrap_or("???").to_owned(), - torrent_link, - magnet_link: item.link().unwrap_or("???").to_owned(), - post_link: post, - file_name: format!("{}.torrent", id), - item_type, - category, - icon, - ..Default::default() - }) - }) - .collect(); - let total_results = items.len(); - sort_items(&mut items, search.sort); - Ok(SourceResponse::Results(ResultResponse { - items, - last_page, - total_results, - })) -} diff --git a/src/source/sukebei_nyaa.rs b/src/source/sukebei_nyaa.rs deleted file mode 100644 index fc69aed..0000000 --- a/src/source/sukebei_nyaa.rs +++ /dev/null @@ -1,395 +0,0 @@ -use std::fmt::Write; -use std::{error::Error, time::Duration}; - -use chrono::{DateTime, Local, NaiveDateTime, TimeZone}; -use ratatui::style::Color; -use reqwest::StatusCode; -use scraper::{Html, Selector}; -use serde::{Deserialize, Serialize}; -use strum::VariantArray as _; -use urlencoding::encode; - -use crate::{ - cats, - results::ResultResponse, - sel, - sync::SearchQuery, - theme::Theme, - util::{ - self, - colors::color_to_tui, - conv::to_bytes, - html::{attr, inner}, - }, - widget::sort::{SelectedSort, SortDir}, -}; - -use super::SourceExtraConfig; -use super::{ - add_protocol, - nyaa_html::{nyaa_table, NyaaColumns, NyaaFilter, NyaaSort}, - nyaa_rss, Item, ItemType, ResultTable, Source, SourceConfig, SourceInfo, SourceResponse, -}; - -#[derive(Serialize, Deserialize, Clone, Copy, Default)] -#[serde(default)] -pub struct SukebeiTheme { - #[serde(rename = "categories")] - pub cat: SukebeiCategoryTheme, -} - -#[derive(Serialize, Deserialize, Clone, Copy)] -#[serde(default)] -pub struct SukebeiCategoryTheme { - #[serde(with = "color_to_tui")] - pub art_anime: Color, - #[serde(with = "color_to_tui")] - pub art_doujinshi: Color, - #[serde(with = "color_to_tui")] - pub art_games: Color, - #[serde(with = "color_to_tui")] - pub art_manga: Color, - #[serde(with = "color_to_tui")] - pub art_pictures: Color, - #[serde(with = "color_to_tui")] - pub real_photos: Color, - #[serde(with = "color_to_tui")] - pub real_videos: Color, -} - -impl Default for SukebeiCategoryTheme { - fn default() -> Self { - use Color::*; - Self { - art_anime: Magenta, - art_doujinshi: LightMagenta, - art_games: Green, - art_manga: LightGreen, - art_pictures: Gray, - real_photos: Red, - real_videos: Yellow, - } - } -} - -#[derive(Serialize, Deserialize, Clone)] -#[serde(default)] -pub struct SukebeiNyaaConfig { - pub base_url: String, - pub default_sort: NyaaSort, - pub default_sort_dir: SortDir, - pub default_filter: NyaaFilter, - pub default_category: String, - pub default_search: String, - pub rss: bool, - pub timeout: Option, - pub columns: Option, -} - -impl Default for SukebeiNyaaConfig { - fn default() -> Self { - Self { - base_url: "https://sukebei.nyaa.si/".to_owned(), - default_sort: NyaaSort::Date, - default_sort_dir: SortDir::Desc, - default_filter: NyaaFilter::NoFilter, - default_category: "AllCategories".to_owned(), - default_search: Default::default(), - rss: false, - timeout: None, - columns: None, - } - } -} - -pub struct SukebeiHtmlSource; - -impl Source for SukebeiHtmlSource { - async fn filter( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> Result> { - SukebeiHtmlSource::search(client, search, config, extra).await - } - async fn categorize( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> Result> { - SukebeiHtmlSource::search(client, search, config, extra).await - } - async fn sort( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> Result> { - let sukebei = config.sukebei.to_owned().unwrap_or_default(); - let sort = search.sort; - let mut res = SukebeiHtmlSource::search(client, search, config, extra).await; - - if sukebei.rss { - if let Ok(SourceResponse::Results(res)) = &mut res { - nyaa_rss::sort_items(&mut res.items, sort); - } - } - res - } - - async fn search( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> Result> { - let sukebei = config.sukebei.to_owned().unwrap_or_default(); - if sukebei.rss { - return nyaa_rss::search_rss::( - sukebei.base_url, - sukebei.timeout, - client, - search, - extra, - ) - .await; - } - let cat = search.category; - let filter = search.filter; - let page = search.page; - let user = search.user.to_owned().unwrap_or_default(); - let sort = NyaaSort::from_repr(search.sort.sort) - .unwrap_or(NyaaSort::Date) - .to_url(); - - let base_url = add_protocol(sukebei.base_url, true)?; - let (high, low) = (cat / 10, cat % 10); - let query = encode(&search.query); - let dir = search.sort.dir.to_url(); - let mut url_query = base_url.clone(); - url_query.set_query(Some(&format!( - "q={}&c={}_{}&f={}&p={}&s={}&o={}&u={}", - query, high, low, filter, page, sort, dir, user - ))); - - let mut request = client.get(url_query.to_owned()); - if let Some(timeout) = sukebei.timeout { - request = request.timeout(Duration::from_secs(timeout)); - } - let response = request.send().await?; - if response.status() != StatusCode::OK { - // Throw error if response code is not OK - let code = response.status().as_u16(); - return Err(format!("{}\nInvalid response code: {}", url_query, code).into()); - } - let content = response.bytes().await?; - let doc = Html::parse_document(std::str::from_utf8(&content[..])?); - - let item_sel = &sel!("table.torrent-list > tbody > tr")?; - let icon_sel = &sel!("td:first-of-type > a")?; - let title_sel = &sel!("td:nth-of-type(2) > a:last-of-type")?; - let torrent_sel = &sel!("td:nth-of-type(3) > a:nth-of-type(1)")?; - let magnet_sel = &sel!("td:nth-of-type(3) > a:nth-of-type(2)")?; - let size_sel = &sel!("td:nth-of-type(4)")?; - let date_sel = &sel!("td:nth-of-type(5)").unwrap(); - let seed_sel = &sel!("td:nth-of-type(6)")?; - let leech_sel = &sel!("td:nth-of-type(7)")?; - let dl_sel = &sel!("td:nth-of-type(8)")?; - let pagination_sel = &sel!(".pagination-page-info")?; - - let mut last_page = 100; - let mut total_results = 7500; - // For searches, pagination has a description of total results found - if let Some(pagination) = doc.select(pagination_sel).next() { - // 6th word in pagination description contains total number of results - if let Some(num_results_str) = pagination.inner_html().split(' ').nth(5) { - if let Ok(num_results) = num_results_str.parse::() { - last_page = (num_results + 74) / 75; - total_results = num_results; - } - } - } - - let items: Vec = doc - .select(item_sel) - .filter_map(|e| { - let cat_str = attr(e, icon_sel, "href"); - let cat_str = cat_str.split('=').last().unwrap_or(""); - let cat = Self::info().entry_from_str(cat_str); - let category = cat.id; - let icon = cat.icon.clone(); - - let torrent = attr(e, torrent_sel, "href"); - let post_link = base_url - .join(&attr(e, title_sel, "href")) - .map(Into::into) - .unwrap_or("null".to_owned()); - let id = post_link.split('/').last()?.parse::().ok()?; - let id = format!("sukebei-{}", id); - let file_name = format!("{}.torrent", id); - - let size = inner(e, size_sel, "0 B") - .replace('i', "") - .replace("Bytes", "B"); - let bytes = to_bytes(&size); - - const DEFAULT_DATE_FORMAT: &str = "%Y-%m-%d %H:%M"; - let date_format = extra.date_format.as_deref().unwrap_or(DEFAULT_DATE_FORMAT); - let date = inner(e, date_sel, ""); - let naive = NaiveDateTime::parse_from_str(&date, date_format).unwrap_or_default(); - let date_time: DateTime = Local.from_utc_datetime(&naive); - - let date = if extra.relative_date.unwrap_or(false) { - util::conv::to_relative_date( - date_time, - extra.relative_date_short.unwrap_or(false), - ) - } else { - let mut newstr = String::new(); - if write!(newstr, "{}", date_time.format(date_format)).is_err() { - newstr = format!("Invalid format string: `{}`", date_format); - } - newstr - }; - - let seeders = inner(e, seed_sel, "0").parse().unwrap_or(0); - let leechers = inner(e, leech_sel, "0").parse().unwrap_or(0); - let downloads = inner(e, dl_sel, "0").parse().unwrap_or(0); - let torrent_link = base_url - .join(&torrent) - .map(Into::into) - .unwrap_or("null".to_owned()); - - let trusted = e.value().classes().any(|e| e == "success"); - let remake = e.value().classes().any(|e| e == "danger"); - let item_type = match (trusted, remake) { - (true, _) => ItemType::Trusted, - (_, true) => ItemType::Remake, - _ => ItemType::None, - }; - - Some(Item { - id, - date, - seeders, - leechers, - downloads, - size, - bytes, - title: attr(e, title_sel, "title"), - torrent_link, - magnet_link: attr(e, magnet_sel, "href"), - post_link, - file_name: file_name.to_owned(), - category, - icon, - item_type, - ..Default::default() - }) - }) - .collect(); - Ok(SourceResponse::Results(ResultResponse { - items, - last_page, - total_results, - })) - // Ok(nyaa_table( - // items, - // &theme, - // &search.sort, - // sukebei.columns, - // last_page, - // total_results, - // )) - } - - async fn solve( - _solution: String, - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> Result> { - SukebeiHtmlSource::search(client, search, config, extra).await - } - - fn info() -> SourceInfo { - let cats = cats! { - "All Categories" => { - 0 => ("---", "All Categories", "AllCategories", fg); - } - "Art" => { - 10 => ("Art", "All Art", "AllArt", fg); - 11 => ("Ani", "Anime", "ArtAnime", source.sukebei.cat.art_anime); - 12 => ("Dou", "Doujinshi", "ArtDoujinshi", source.sukebei.cat.art_doujinshi); - 13 => ("Gam", "Games", "ArtGames", source.sukebei.cat.art_games); - 14 => ("Man", "Manga", "ArtManga", source.sukebei.cat.art_manga); - 15 => ("Pic", "Pictures", "ArtPictures", source.sukebei.cat.art_pictures); - } - "Real Life" => { - 20 => ("Rea", "All Real Life", "AllReal", fg); - 21 => ("Pho", "Photobooks and Pictures", "RealPhotos", source.sukebei.cat.real_photos); - 22 => ("Vid", "Videos", "RealVideos", source.sukebei.cat.real_videos); - } - }; - SourceInfo { - cats, - filters: NyaaFilter::VARIANTS - .iter() - .map(ToString::to_string) - .collect(), - sorts: NyaaSort::VARIANTS.iter().map(ToString::to_string).collect(), - } - } - - fn load_config(config: &mut SourceConfig) { - if config.sukebei.is_none() { - config.sukebei = Some(SukebeiNyaaConfig::default()); - } - } - - fn default_category(cfg: &SourceConfig) -> usize { - let default = cfg - .sukebei - .as_ref() - .map(|c| c.default_category.to_owned()) - .unwrap_or_default(); - Self::info().entry_from_cfg(&default).id - } - - fn default_sort(cfg: &SourceConfig) -> SelectedSort { - cfg.sukebei - .as_ref() - .map(|c| SelectedSort { - sort: c.default_sort as usize, - dir: c.default_sort_dir, - }) - .unwrap_or_default() - } - - fn default_filter(cfg: &SourceConfig) -> usize { - cfg.sukebei - .as_ref() - .map(|c| c.default_filter as usize) - .unwrap_or_default() - } - - fn default_search(cfg: &SourceConfig) -> String { - cfg.sukebei - .as_ref() - .map(|c| c.default_search.to_owned()) - .unwrap_or_default() - } - - fn format_table( - items: &[Item], - search: &SearchQuery, - config: &SourceConfig, - theme: &Theme, - ) -> ResultTable { - let sukebei = config.sukebei.to_owned().unwrap_or_default(); - nyaa_table(items.into(), theme, &search.sort, &sukebei.columns) - } -} diff --git a/src/source/torrent_galaxy.rs b/src/source/torrent_galaxy.rs deleted file mode 100644 index e5cf148..0000000 --- a/src/source/torrent_galaxy.rs +++ /dev/null @@ -1,852 +0,0 @@ -use std::{ - cmp::max, - collections::HashMap, - error::Error, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; - -use ratatui::{ - layout::{Alignment, Constraint}, - style::{Color, Stylize}, -}; -use reqwest::{StatusCode, Url}; -use scraper::{selectable::Selectable, Html, Selector}; -use serde::{Deserialize, Serialize}; -use strum::{FromRepr, VariantArray}; -use urlencoding::encode; - -use crate::{ - cats, collection, cond_vec, - results::{ResultColumn, ResultHeader, ResultResponse, ResultRow, ResultTable}, - sel, - sync::SearchQuery, - theme::Theme, - util::{ - colors::color_to_tui, - conv::{shorten_number, to_bytes}, - html::{as_type, attr, inner}, - }, - widget::sort::{SelectedSort, SortDir}, -}; - -use super::{ - add_protocol, Item, ItemType, Source, SourceConfig, SourceExtraConfig, SourceInfo, - SourceResponse, -}; - -#[derive(Serialize, Deserialize, Clone, Copy, Default)] -#[serde(default)] -pub struct TgxTheme { - #[serde(rename = "categories")] - pub cat: TgxCategoryTheme, -} - -#[derive(Serialize, Deserialize, Clone, Copy)] -#[serde(default)] -pub struct TgxCategoryTheme { - #[serde(with = "color_to_tui")] - pub all_categories: Color, - #[serde(with = "color_to_tui")] - pub movies_4k: Color, - #[serde(with = "color_to_tui")] - pub movies_bollywood: Color, - #[serde(with = "color_to_tui")] - pub movies_cam: Color, - #[serde(with = "color_to_tui")] - pub movies_hd: Color, - #[serde(with = "color_to_tui")] - pub movies_pack: Color, - #[serde(with = "color_to_tui")] - pub movies_sd: Color, - #[serde(with = "color_to_tui")] - pub tv_hd: Color, - #[serde(with = "color_to_tui")] - pub tv_sd: Color, - #[serde(with = "color_to_tui")] - pub tv_4k: Color, - #[serde(with = "color_to_tui")] - pub tv_pack: Color, - #[serde(with = "color_to_tui")] - pub tv_sports: Color, - #[serde(with = "color_to_tui")] - pub anime: Color, - #[serde(with = "color_to_tui")] - pub apps_mobile: Color, - #[serde(with = "color_to_tui")] - pub apps_other: Color, - #[serde(with = "color_to_tui")] - pub apps_windows: Color, - #[serde(with = "color_to_tui")] - pub audiobooks: Color, - #[serde(with = "color_to_tui")] - pub comics: Color, - #[serde(with = "color_to_tui")] - pub ebooks: Color, - #[serde(with = "color_to_tui")] - pub educational: Color, - #[serde(with = "color_to_tui")] - pub magazines: Color, - #[serde(with = "color_to_tui")] - pub documentaries: Color, - #[serde(with = "color_to_tui")] - pub games_windows: Color, - #[serde(with = "color_to_tui")] - pub games_other: Color, - #[serde(with = "color_to_tui")] - pub music_albums: Color, - #[serde(with = "color_to_tui")] - pub music_discography: Color, - #[serde(with = "color_to_tui")] - pub music_lossless: Color, - #[serde(with = "color_to_tui")] - pub music_video: Color, - #[serde(with = "color_to_tui")] - pub music_singles: Color, - #[serde(with = "color_to_tui")] - pub audio_other: Color, - #[serde(with = "color_to_tui")] - pub pictures_other: Color, - #[serde(with = "color_to_tui")] - pub training_other: Color, - #[serde(with = "color_to_tui")] - pub other: Color, - #[serde(with = "color_to_tui")] - pub xxx_4k: Color, - #[serde(with = "color_to_tui")] - pub xxx_hd: Color, - #[serde(with = "color_to_tui")] - pub xxx_misc: Color, - #[serde(with = "color_to_tui")] - pub xxx_sd: Color, -} - -impl Default for TgxCategoryTheme { - fn default() -> Self { - use Color::*; - Self { - all_categories: White, - movies_4k: LightMagenta, - movies_bollywood: Green, - movies_cam: LightCyan, - movies_hd: LightBlue, - movies_pack: Magenta, - movies_sd: Yellow, - tv_hd: Green, - tv_sd: LightCyan, - tv_4k: LightMagenta, - tv_pack: Blue, - tv_sports: LightGreen, - anime: LightMagenta, - apps_mobile: LightGreen, - apps_other: Magenta, - apps_windows: LightCyan, - audiobooks: Yellow, - comics: LightGreen, - ebooks: Green, - educational: Yellow, - magazines: Green, - documentaries: LightYellow, - games_windows: LightCyan, - games_other: Yellow, - music_albums: Cyan, - music_discography: Magenta, - music_lossless: LightBlue, - music_video: Green, - music_singles: LightYellow, - audio_other: LightGreen, - pictures_other: Green, - training_other: LightBlue, - other: Yellow, - xxx_4k: Red, - xxx_hd: Red, - xxx_misc: Red, - xxx_sd: Red, - } - } -} - -#[derive(Serialize, Deserialize, Clone)] -#[serde(default)] -pub struct TgxConfig { - pub base_url: String, - pub default_sort: TgxSort, - pub default_sort_dir: SortDir, - pub default_filter: TgxFilter, - pub default_category: String, - pub default_search: String, - pub timeout: Option, - pub columns: Option, -} - -impl Default for TgxConfig { - fn default() -> Self { - Self { - base_url: "https://torrentgalaxy.to/".to_owned(), - default_sort: TgxSort::Date, - default_sort_dir: SortDir::Desc, - default_filter: TgxFilter::NoFilter, - default_category: "AllCategories".to_owned(), - default_search: Default::default(), - timeout: None, - columns: None, - } - } -} - -#[derive(Clone, Copy, Serialize, Deserialize, Default)] -pub struct TgxColumns { - category: Option, - language: Option, - title: Option, - imdb: Option, - uploader: Option, - size: Option, - date: Option, - seeders: Option, - leechers: Option, - views: Option, -} - -impl TgxColumns { - fn array(self) -> [bool; 10] { - [ - self.category.unwrap_or(true), - self.language.unwrap_or(true), - self.title.unwrap_or(true), - self.imdb.unwrap_or(true), - self.uploader.unwrap_or(true), - self.size.unwrap_or(true), - self.date.unwrap_or(true), - self.seeders.unwrap_or(true), - self.leechers.unwrap_or(true), - self.views.unwrap_or(true), - ] - } -} - -#[derive( - Serialize, Deserialize, strum::Display, Clone, Copy, VariantArray, PartialEq, Eq, FromRepr, -)] -#[repr(usize)] -pub enum TgxSort { - Date = 0, - Seeders = 1, - Leechers = 2, - Size = 3, - Name = 4, -} - -#[derive( - Serialize, Deserialize, strum::Display, Clone, Copy, VariantArray, PartialEq, Eq, FromRepr, -)] -pub enum TgxFilter { - #[allow(clippy::enum_variant_names)] - #[strum(serialize = "NoFilter")] - NoFilter = 0, - #[strum(serialize = "Filter online streams")] - OnlineStreams = 1, - #[strum(serialize = "Exclude XXX")] - ExcludeXXX = 2, - #[strum(serialize = "No wildcard")] - NoWildcard = 3, -} - -pub struct TorrentGalaxyHtmlSource; - -fn get_url( - base_url: String, - search: &SearchQuery, -) -> Result<(Url, Url), Box> { - let base_url = add_protocol(base_url, true)?.join("torrents.php")?; - - let query = encode(&search.query); - - let sort = match TgxSort::from_repr(search.sort.sort) { - Some(TgxSort::Date) => "&sort=id", - Some(TgxSort::Seeders) => "&sort=seeders", - Some(TgxSort::Leechers) => "&sort=leechers", - Some(TgxSort::Size) => "&sort=size", - Some(TgxSort::Name) => "&sort=name", - _ => "", - }; - let ord = format!("&order={}", search.sort.dir.to_url()); - let filter = match TgxFilter::from_repr(search.filter) { - Some(TgxFilter::OnlineStreams) => "&filterstream=1", - Some(TgxFilter::ExcludeXXX) => "&nox=1", - Some(TgxFilter::NoWildcard) => "&nowildcard=1", - _ => "", - }; - let cat = match search.category { - 0 => "".to_owned(), - x => format!("&c{}=1", x), - }; - - let q = format!( - "search={}&page={}{}{}{}{}", - query, - search.page - 1, - filter, - cat, - sort, - ord - ); - let mut url = base_url.clone(); - url.set_query(Some(&q)); - Ok((base_url, url)) -} - -async fn try_get_content( - client: &reqwest::Client, - timeout: Option, - url: &Url, -) -> Result> { - let mut request = client.get(url.to_owned()); - if let Some(timeout) = timeout { - request = request.timeout(Duration::from_secs(timeout)); - } - let response = request - .header( - "User-Agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0", - ) - .send() - .await?; - if response.status() != StatusCode::OK { - // Throw error if response code is not OK - let code = response.status().as_u16(); - return Err(format!("{}\nInvalid response code: {}", url, code).into()); - } - Ok(response.text().await?) -} - -fn get_lang(full_name: String) -> String { - match full_name.as_str() { - "English" => "en", - "French" => "fr", - "German" => "de", - "Italian" => "it", - "Japanese" => "jp", - "Spanish" => "es", - "Russian" => "ru", - "Norwegian" => "no", - "Hindi" => "hi", - "Korean" => "ko", - "Danish" => "da", - "Dutch" => "nl", - "Chinese" => "zh", - "Portuguese" => "pt", - "Polish" => "pl", - "Turkish" => "tr", - "Telugu" => "te", - "Swedish" => "sv", - "Czech" => "cs", - "Arabic" => "ar", - "Romanian" => "ro", - "Bengali" => "bn", - "Urdu" => "ur", - "Thai" => "th", - "Tamil" => "ta", - "Croatian" => "hr", - _ => "??", - } - .to_owned() -} - -fn get_status_color(status: String) -> Option { - match status.as_str() { - "Trial Uploader" => Some(Color::Magenta), - "Trusted Uploader" => Some(Color::LightGreen), - "Trusted User" => Some(Color::Cyan), - "Moderator" => Some(Color::Red), - "Admin" => Some(Color::Yellow), - "Torrent Officer" => Some(Color::LightYellow), - "Verified Uploader" => Some(Color::LightBlue), - _ => None, - } -} - -impl Source for TorrentGalaxyHtmlSource { - async fn filter( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> Result> { - TorrentGalaxyHtmlSource::search(client, search, config, extra).await - } - async fn categorize( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> Result> { - TorrentGalaxyHtmlSource::search(client, search, config, extra).await - } - async fn sort( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> Result> { - TorrentGalaxyHtmlSource::search(client, search, config, extra).await - } - - async fn search( - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - _extra: &SourceExtraConfig, - ) -> Result> { - let tgx = config.tgx.to_owned().unwrap_or_default(); - let (base_url, url) = get_url(tgx.base_url.clone(), search)?; - - let table_sel = &sel!(".tgxtable")?; - - // First try checkpoint - let content = try_get_content(client, tgx.timeout, &url).await?; - if Html::parse_document(&content).select(table_sel).count() == 0 { - let time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); - - let hash = "4578678889c4b42ae37b543434c81d85"; - let mut hash_url = base_url.clone().join("hub.php")?; - hash_url.set_query(Some(&format!("a=vlad&u={}", time))); - client - .post(hash_url.clone()) - .body(format!("fash={}", hash)) - .header("Content-Type", "application/x-www-form-urlencoded") - .header( - "User-Agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0", - ) - .send() - .await?; - } - - // If that doesn't work, try making the user solve a captcha - let content = try_get_content(client, tgx.timeout, &url).await?; - if Html::parse_document(&content).select(table_sel).count() == 0 { - #[cfg(not(feature = "captcha"))] - { - return Err("Unable to get response, most likely due to rate limit.\nWait a bit before retrying...".into()); - } - #[cfg(feature = "captcha")] - { - let mut captcha_url = base_url.clone().join("captcha/cpt_show.pnp")?; - captcha_url.set_query(Some("v=txlight&63fd4c746843c74b53ca60277192fb48")); - let mut request = client.get(captcha_url); - if let Some(timeout) = tgx.timeout { - request = request.timeout(Duration::from_secs(timeout)); - } - let response = request - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0") - .send() - .await?; - let bytes = response.bytes().await?; - let mut picker = ratatui_image::picker::Picker::new((1, 2)); - picker.protocol_type = ratatui_image::picker::ProtocolType::Halfblocks; - let dyn_image = image::load_from_memory(&bytes[..])?; - let image = picker.new_resize_protocol(dyn_image); - - return Ok(SourceResponse::Captcha(image)); - } - } - - // Results table found, can start parsing - let doc = Html::parse_document(&content); - - let item_sel = &sel!("div.tgxtablerow")?; - let title_sel = &sel!("div.tgxtablecell:nth-of-type(4) > div > a.txlight")?; - let imdb_sel = &sel!("div.tgxtablecell:nth-of-type(4) > div > a:last-of-type")?; - let cat_sel = &sel!("div.tgxtablecell:nth-of-type(1) > a")?; - let date_sel = &sel!("div.tgxtablecell:nth-of-type(12)")?; - let seed_sel = &sel!("div.tgxtablecell:nth-of-type(11) > span > font:first-of-type > b")?; - let leech_sel = &sel!("div.tgxtablecell:nth-of-type(11) > span > font:last-of-type > b")?; - let size_sel = &sel!("div.tgxtablecell:nth-of-type(8) > span")?; - let trust_sel = &sel!("div.tgxtablecell:nth-of-type(2) > i")?; - let views_sel = &sel!("div.tgxtablecell:nth-of-type(10) > span > font > b")?; - let torrent_sel = &sel!("div.tgxtablecell:nth-of-type(5) > a:first-of-type")?; - let magnet_sel = &sel!("div.tgxtablecell:nth-of-type(5) > a:last-of-type")?; - let lang_sel = &sel!("div.tgxtablecell:nth-of-type(3) > img")?; - let uploader_sel = &sel!("div.tgxtablecell:nth-of-type(7) > span > a > span")?; - let uploader_status_sel = &sel!("div.tgxtablecell:nth-of-type(7) > span > a")?; - - let pagination_sel = &sel!("div#filterbox2 > span.badge")?; - - let items = doc - .select(item_sel) - .filter_map(|e| { - let cat_id = attr(e, cat_sel, "href") - .rsplit_once('=') - .map(|v| v.1) - .and_then(|v| v.parse::().ok()) - .unwrap_or_default(); - let icon = Self::info().entry_from_id(cat_id).icon; - let date = e - .select(date_sel) - .nth(0) - .map(|e| e.text().collect()) - .unwrap_or_default(); - let seeders = as_type(inner(e, seed_sel, "0")).unwrap_or_default(); - let leechers = as_type(inner(e, leech_sel, "0")).unwrap_or_default(); - let views = as_type(inner(e, views_sel, "0")).unwrap_or_default(); - let mut size = inner(e, size_sel, "0 MB"); - - // Convert numbers like 1,015 KB => 1.01 MB - if let Some((x, y)) = size.split_once(',') { - if let Some((y, unit)) = y.split_once(' ') { - let y = y.get(0..2).unwrap_or("00"); - // find next unit up - let unit = match unit.to_lowercase().as_str() { - "b" => "kB", - "kb" => "MB", - "mb" => "GB", - "gb" => "TB", - _ => "??", - }; - size = format!("{}.{} {}", x, y, unit); - } - } - - let item_type = match e - .select(trust_sel) - .nth(0) - .map(|v| v.value().classes().any(|e| e == "fa-check")) - .unwrap_or(false) - { - true => ItemType::None, - false => ItemType::Remake, - }; - - let torrent_link: String = base_url - .join(&attr(e, torrent_sel, "href")) - .map(Into::into) - .unwrap_or_default(); - let magnet_link = attr(e, magnet_sel, "href"); - let post_link = attr(e, title_sel, "href"); - - let binding = post_link.split('/').collect::>(); - let id = format!("tgx-{}", binding.get(2)?); - - let post_link = base_url - .join(&post_link) - .map(Into::into) - .unwrap_or_default(); - let hash = torrent_link.split('/').nth(4).unwrap_or("unknown"); - let file_name = format!("{}.torrent", hash); - - let imdb = attr(e, imdb_sel, "href"); - let imdb = match imdb.rsplit_once('=').map(|r| r.1).unwrap_or("") { - "tt2000000" => "", // For some reason, most XXX titles use this ID - i => i, - }; - - let extra: HashMap = collection![ - "uploader".to_owned() => inner(e, uploader_sel, "???"), - "uploader_status".to_owned() => attr(e, uploader_status_sel, "title"), - "lang".to_owned() => attr(e, lang_sel, "title"), - "imdb".to_owned() => imdb.to_owned(), - ]; - - Some(Item { - id, - date, - seeders, - leechers, - downloads: views, - bytes: to_bytes(&size), - size, - title: attr(e, title_sel, "title"), - torrent_link, - magnet_link, - post_link, - file_name, - category: cat_id, - icon, - item_type, - extra, - }) - }) - .collect::>(); - - let mut last_page = 50; - let mut total_results = 2500; - if let Some(pagination) = doc.select(pagination_sel).nth(0) { - if let Ok(num_results) = pagination - .inner_html() - .chars() - .filter(|c| c.is_ascii_digit()) - .collect::() - .parse::() - { - if num_results != 0 || items.is_empty() { - last_page = (num_results + 49) / 50; - total_results = num_results; - } - } - } - - Ok(SourceResponse::Results(ResultResponse { - items, - total_results, - last_page, - })) - } - - async fn solve( - solution: String, - client: &reqwest::Client, - search: &SearchQuery, - config: &SourceConfig, - extra: &SourceExtraConfig, - ) -> Result> { - let tgx = config.tgx.to_owned().unwrap_or_default(); - let time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); - - let hash = "4578678889c4b42ae37b543434c81d85"; - let base_url = Url::parse(&tgx.base_url)?; - let mut hash_url = base_url.clone().join("hub.php")?; - hash_url.set_query(Some(&format!("a=vlad&u={}", time))); - client - .post(hash_url.clone()) - .body(format!("fash={}", hash)) - .header("Content-Type", "application/x-www-form-urlencoded") - .header( - "User-Agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0", - ) - .send() - .await?; - - let (_base_url, url) = get_url(tgx.base_url, search)?; - let mut full_url = base_url.clone().join("galaxyfence.php")?; - full_url.set_query(Some(&format!( - "captcha={}&dropoff={}", - solution, - encode(&format!( - "{}?{}", - url.path(), - url.query().unwrap_or_default() - )) - ))); - let mut request = client.post(full_url.clone()); - if let Some(timeout) = tgx.timeout { - request = request.timeout(Duration::from_secs(timeout)); - } - request = request.header( - "Accept", - "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", - ) - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0") - .header("Content-Type", "application/x-www-form-urlencoded"); - - let response = request.send().await?; - if response.status() != StatusCode::OK { - return Err(format!( - "Captcha solution returned HTTP status {}", - response.status() - ) - .into()); - } - - TorrentGalaxyHtmlSource::search(client, search, config, extra).await - } - - fn info() -> SourceInfo { - let cats = cats! { - "All Categories" => { 0 => ("---", "All Categories", "AllCategories", source.tgx.cat.all_categories); } - "Movies" => {3 => ("4kM", "4K UHD Movies", "4kMovies", source.tgx.cat.movies_4k); - 46 => ("Bly", "Bollywood", "Bollywood Movies", source.tgx.cat.movies_bollywood); - 45 => ("Cam", "Cam/TS", "CamMovies", source.tgx.cat.movies_cam); - 42 => ("HdM", "HD Movies", "HdMovies", source.tgx.cat.movies_hd); - 4 => ("PkM", "Movie Packs", "PackMovies", source.tgx.cat.movies_pack); - 1 => ("SdM", "SD Movies", "SdMovies", source.tgx.cat.movies_sd);} - "TV" => {41 => ("HdT", "TV HD", "HdTV", source.tgx.cat.tv_hd); - 5 => ("SdT", "TV SD", "SdTV", source.tgx.cat.tv_sd); - 11 => ("4kT", "TV 4k", "4kTV", source.tgx.cat.tv_4k); - 6 => ("PkT", "TV Packs", "PacksTV", source.tgx.cat.tv_pack); - 7 => ("Spo", "Sports", "SportsTV", source.tgx.cat.tv_sports);} - "Anime" => {28 => ("Ani", "All Anime", "Anime", source.tgx.cat.anime);} - "Apps" => {20 => ("Mob", "Mobile Apps", "AppsMobile", source.tgx.cat.apps_mobile); - 21 => ("App", "Other Apps", "AppsOther", source.tgx.cat.apps_other); - 18 => ("Win", "Windows Apps", "AppsWindows", source.tgx.cat.apps_windows);} - "Books" => {13 => ("Abk", "Audiobooks", "Audiobooks", source.tgx.cat.audiobooks); - 19 => ("Com", "Comics", "Comics", source.tgx.cat.comics); - 12 => ("Ebk", "Ebooks", "Ebooks", source.tgx.cat.ebooks); - 14 => ("Edu", "Educational", "Educational", source.tgx.cat.educational); - 15 => ("Mag", "Magazines", "Magazines", source.tgx.cat.magazines);} - "Documentaries" => {9 => ("Doc", "All Documentaries", "Documentaries", source.tgx.cat.documentaries);} - "Games" => {10 => ("Wgm", "Windows Games", "WindowsGames", source.tgx.cat.games_windows); - 43 => ("Ogm", "Other Games", "OtherGames", source.tgx.cat.games_other);} - "Music" => {22 => ("Alb", "Music Albums", "AlbumsMusic", source.tgx.cat.music_albums); - 26 => ("Dis", "Music Discography", "DiscographyMusic", source.tgx.cat.music_discography); - 23 => ("Los", "Music Lossless", "LosslessMusic", source.tgx.cat.music_lossless); - 25 => ("MV ", "Music Video", "MusicVideo", source.tgx.cat.music_video); - 24 => ("Sin", "Music Singles", "SinglesMusic", source.tgx.cat.music_singles);} - "Other" => {17 => ("Aud", "Other Audio", "AudioOther", source.tgx.cat.audio_other); - 40 => ("Pic", "Other Pictures", "PicturesOther", source.tgx.cat.pictures_other); - 37 => ("Tra", "Other Training", "TrainingOther", source.tgx.cat.training_other); - 33 => ("Oth", "Other", "Other", source.tgx.cat.other);} - "XXX" => {48 => ("4kX", "XXX 4k", "4kXXX", source.tgx.cat.xxx_4k); - 35 => ("HdX", "XXX HD", "HdXXX", source.tgx.cat.xxx_hd); - 47 => ("MsX", "XXX Misc", "MiscXXX", source.tgx.cat.xxx_misc); - 34 => ("SdX", "XXX SD", "SdXXX", source.tgx.cat.xxx_sd);} - }; - SourceInfo { - cats, - filters: TgxFilter::VARIANTS - .iter() - .map(ToString::to_string) - .collect(), - sorts: TgxSort::VARIANTS.iter().map(ToString::to_string).collect(), - } - } - - fn load_config(config: &mut SourceConfig) { - if config.tgx.is_none() { - config.tgx = Some(TgxConfig::default()); - } - } - - fn default_category(cfg: &SourceConfig) -> usize { - let default = cfg - .tgx - .as_ref() - .map(|c| c.default_category.to_owned()) - .unwrap_or_default(); - Self::info().entry_from_cfg(&default).id - } - - fn default_sort(cfg: &SourceConfig) -> SelectedSort { - cfg.tgx - .as_ref() - .map(|c| SelectedSort { - sort: c.default_sort as usize, - dir: c.default_sort_dir, - }) - .unwrap_or_default() - } - - fn default_filter(cfg: &SourceConfig) -> usize { - cfg.tgx - .as_ref() - .map(|c| c.default_filter as usize) - .unwrap_or_default() - } - - fn default_search(cfg: &SourceConfig) -> String { - cfg.tgx - .as_ref() - .map(|c| c.default_search.to_owned()) - .unwrap_or_default() - } - - fn format_table( - items: &[Item], - search: &SearchQuery, - config: &SourceConfig, - theme: &Theme, - ) -> ResultTable { - let tgx = config.tgx.to_owned().unwrap_or_default(); - let raw_date_width = items.iter().map(|i| i.date.len()).max().unwrap_or_default() as u16; - let date_width = max(raw_date_width, 6); - - let raw_uploader_width = items - .iter() - .map(|i| i.extra.get("uploader").map(|u| u.len()).unwrap_or(0)) - .max() - .unwrap_or_default() as u16; - let uploader_width = max(raw_uploader_width, 8); - let raw_imdb_width = items - .iter() - .map(|i| i.extra.get("imdb").map(|u| u.len()).unwrap_or(0)) - .max() - .unwrap_or_default() as u16; - let imdb_width = max(raw_imdb_width, 4); - - let header = ResultHeader::new([ - ResultColumn::Normal("Cat".to_owned(), Constraint::Length(3)), - ResultColumn::Normal("".to_owned(), Constraint::Length(2)), - ResultColumn::Normal("Name".to_owned(), Constraint::Min(3)), - ResultColumn::Normal("imdb".to_owned(), Constraint::Length(imdb_width)), - ResultColumn::Normal("Uploader".to_owned(), Constraint::Length(uploader_width)), - ResultColumn::Sorted("Size".to_owned(), 9, TgxSort::Size as u32), - ResultColumn::Sorted("Date".to_owned(), date_width, TgxSort::Date as u32), - ResultColumn::Sorted("".to_owned(), 4, TgxSort::Seeders as u32), - ResultColumn::Sorted("".to_owned(), 4, TgxSort::Leechers as u32), - ResultColumn::Normal(" 󰈈".to_owned(), Constraint::Length(5)), - ]); - let mut binding = header.get_binding(); - let align = [ - Alignment::Left, - Alignment::Left, - Alignment::Left, - Alignment::Left, - Alignment::Left, - Alignment::Right, - Alignment::Left, - Alignment::Right, - Alignment::Right, - Alignment::Left, - ]; - let mut rows: Vec = items - .iter() - .map(|item| { - ResultRow::new([ - item.icon.label.fg((item.icon.color)(theme)), - item.extra - .get("lang") - .map(|l| get_lang(l.to_owned())) - .unwrap_or("??".to_owned()) - .fg(theme.fg), - item.title.to_owned().fg(match item.item_type { - ItemType::Trusted => theme.success, - ItemType::Remake => theme.error, - ItemType::None => theme.fg, - }), - item.extra - .get("imdb") - .cloned() - .unwrap_or_default() - .fg(theme.fg), - item.extra - .get("uploader") - .cloned() - .unwrap_or("???".to_owned()) - .fg(item - .extra - .get("uploader_status") - .and_then(|u| get_status_color(u.to_owned())) - .unwrap_or(theme.fg)), - item.size.clone().fg(theme.fg), - item.date.clone().fg(theme.fg), - item.seeders.to_string().fg(theme.success), - item.leechers.to_string().fg(theme.error), - shorten_number(item.downloads).fg(theme.fg), - ]) - .aligned(align) - .fg(theme.fg) - }) - .collect(); - let mut headers = header.get_row(search.sort.dir, search.sort.sort as u32); - if let Some(columns) = tgx.columns { - let cols = columns.array(); - - headers.cells = cond_vec!(cols ; headers.cells); - rows = rows - .clone() - .into_iter() - .map(|mut r| { - r.cells = cond_vec!(cols ; r.cells.to_owned()); - r - }) - .collect::>(); - binding = cond_vec!(cols ; binding); - } - - ResultTable { - headers, - rows, - binding, - } - } -} diff --git a/src/sources.rs b/src/sources.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/sources/nyaa.rs b/src/sources/nyaa.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/sync.rs b/src/sync.rs deleted file mode 100644 index 6a29401..0000000 --- a/src/sync.rs +++ /dev/null @@ -1,177 +0,0 @@ -use std::{ - error::Error, - fs, - path::PathBuf, - time::{Duration, SystemTime}, -}; - -use crossterm::event::{self, Event}; -use tokio::sync::mpsc; - -use crate::{ - app::LoadType, - client::{Client, ClientConfig, DownloadClientResult}, - config::CONFIG_FILE, - results::Results, - source::{Item, SourceConfig, SourceExtraConfig, SourceResponse, SourceResults, Sources}, - theme::{Theme, THEMES_PATH}, - widget::sort::SelectedSort, -}; - -pub trait EventSync { - #[allow(clippy::too_many_arguments)] - fn load_results( - self, - tx_res: mpsc::Sender>>, - load_type: LoadType, - src: Sources, - client: reqwest::Client, - search: SearchQuery, - config: SourceConfig, - theme: Theme, - extra: SourceExtraConfig, - ) -> impl std::future::Future + std::marker::Send + 'static; - fn download( - self, - tx_dl: mpsc::Sender, - batch: bool, - items: Vec, - config: ClientConfig, - rq_client: reqwest::Client, - client: Client, - ) -> impl std::future::Future + std::marker::Send + 'static; - fn read_event_loop( - self, - tx_evt: mpsc::Sender, - ) -> impl std::future::Future + std::marker::Send + 'static; - fn watch_config_loop( - self, - tx_evt: mpsc::Sender, - ) -> impl std::future::Future + std::marker::Send + 'static; -} - -#[derive(Clone)] -pub struct AppSync { - config_path: PathBuf, -} - -impl AppSync { - pub fn new(config_path: PathBuf) -> Self { - Self { config_path } - } -} - -#[derive(Clone, Default)] -pub struct SearchQuery { - pub query: String, - pub page: usize, - pub category: usize, - pub filter: usize, - pub sort: SelectedSort, - pub user: Option, -} - -#[derive(Clone)] -pub enum ReloadType { - Config, - Theme(String), -} - -fn watch(path: &PathBuf, last_modified: SystemTime) -> bool { - if let Ok(meta) = fs::metadata(path) { - if let Ok(time) = meta.modified() { - if time > last_modified { - return true; - } - } - } - false -} - -impl EventSync for AppSync { - async fn load_results( - self, - tx_res: mpsc::Sender>>, - load_type: LoadType, - src: Sources, - client: reqwest::Client, - search: SearchQuery, - config: SourceConfig, - theme: Theme, - extra: SourceExtraConfig, - ) { - let res = src.load(load_type, &client, &search, &config, &extra).await; - let fmt = match res { - Ok(SourceResponse::Results(res)) => Ok(SourceResults::Results(Results::new( - search.clone(), - res.clone(), - src.format_table(&res.items, &search, &config, &theme), - ))), - #[cfg(feature = "captcha")] - Ok(SourceResponse::Captcha(c)) => Ok(SourceResults::Captcha(c)), - Err(e) => Err(e), - }; - let _ = tx_res.send(fmt).await; - } - - async fn download( - self, - tx_dl: mpsc::Sender, - batch: bool, - items: Vec, - config: ClientConfig, - rq_client: reqwest::Client, - client: Client, - ) { - let res = match batch { - true => { - DownloadClientResult::Batch(client.batch_download(items, config, rq_client).await) - } - false => DownloadClientResult::Single( - client.download(items[0].clone(), config, rq_client).await, - ), - }; - let _ = tx_dl.send(res).await; - } - - async fn read_event_loop(self, tx_evt: mpsc::Sender) { - loop { - if let Ok(evt) = event::read() { - let _ = tx_evt.send(evt).await; - } - } - } - - async fn watch_config_loop(self, tx_cfg: mpsc::Sender) { - let config_path = self.config_path.clone(); - let config_file = config_path.join(CONFIG_FILE); - let themes_path = config_path.join(THEMES_PATH); - let now = SystemTime::now(); - - let mut last_modified = now; - loop { - if watch(&config_file, last_modified) { - last_modified = SystemTime::now(); - let _ = tx_cfg.send(ReloadType::Config).await; - } - let theme_files = fs::read_dir(&themes_path).ok().and_then(|v| { - v.filter_map(Result::ok) - .map(|v| v.path()) - .find(|p| watch(p, last_modified)) - }); - if let Some(theme) = theme_files { - last_modified = SystemTime::now(); - let _ = tx_cfg - .send(ReloadType::Theme( - theme - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(), - )) - .await; - } - tokio::time::sleep(Duration::from_millis(500)).await; - } - } -} diff --git a/src/theme.rs b/src/theme.rs deleted file mode 100644 index 728fea3..0000000 --- a/src/theme.rs +++ /dev/null @@ -1,208 +0,0 @@ -use std::{ - error::Error, - fs, - path::{Path, PathBuf}, -}; - -use indexmap::IndexMap; -use ratatui::{prelude::Color, widgets::BorderType}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -use crate::{app::Context, collection, config, source::SourceTheme, util::colors::color_to_tui}; - -pub static THEMES_PATH: &str = "themes"; - -#[derive(Clone, Serialize, Deserialize)] -pub struct Theme { - pub name: String, - #[serde(with = "color_to_tui")] - pub bg: Color, - #[serde(with = "color_to_tui")] - pub fg: Color, - #[serde( - deserialize_with = "border_deserialize", - serialize_with = "border_serialize" - )] - pub border: BorderType, - #[serde(with = "color_to_tui")] - pub border_color: Color, - #[serde(with = "color_to_tui")] - pub border_focused_color: Color, - #[serde(with = "color_to_tui")] - pub hl_bg: Color, - #[serde(with = "color_to_tui")] - pub solid_bg: Color, - #[serde(with = "color_to_tui")] - pub solid_fg: Color, - #[serde(with = "color_to_tui", alias = "remake")] - pub info: Color, - #[serde(with = "color_to_tui", alias = "remake")] - pub warning: Color, - #[serde(with = "color_to_tui", alias = "remake")] - pub error: Color, - #[serde(with = "color_to_tui", alias = "trusted")] - pub success: Color, - - #[serde(default)] - pub source: SourceTheme, -} - -pub fn load_user_themes(ctx: &mut Context, config_path: PathBuf) -> Result<(), String> { - let path = config_path.join(THEMES_PATH); - if !path.exists() { - return Ok(()); // Allow no theme folder - } - let path_str = path.to_owned(); - let path_str = path_str.to_string_lossy(); - if !path.is_dir() { - return Err(format!("\"{}\" is not a directory", path_str)); - } - let dir = match fs::read_dir(path) { - Ok(d) => d, - Err(e) => return Err(format!("Can't read directory \"{}\":{}\n", path_str, e)), - }; - let themes = dir - .filter_map(|f| { - let f = match f { - Ok(f) => f, - Err(e) => { - ctx.notify_error(format!("Failed to get theme file path :\n{}", e)); - return None; - } - }; - let res = match Theme::from_path(f.path()) { - Ok(t) => t, - Err(e) => { - ctx.notify_error(format!( - "Failed to parse theme \"{}\":\n{}", - f.file_name().to_string_lossy(), - e - )); - return None; - } - }; - Some((res.name.to_owned(), res)) - }) - .collect::>(); - - ctx.themes.extend(themes); - Ok(()) -} - -pub fn border_serialize( - border: &BorderType, - serializer: S, -) -> Result { - serializer.serialize_str(&match border { - BorderType::Plain => "Plain".to_owned(), - BorderType::Rounded => "Rounded".to_owned(), - BorderType::Double => "Double".to_owned(), - BorderType::Thick => "Thick".to_owned(), - BorderType::QuadrantInside => "QuadrantInside".to_owned(), - BorderType::QuadrantOutside => "QuadrantOutside".to_owned(), - }) -} - -pub fn border_deserialize<'de, D: Deserializer<'de>>( - deserializer: D, -) -> Result { - use serde::de::{Error, Unexpected}; - - let border_string = String::deserialize(deserializer)?; - - Ok(match border_string.to_lowercase().as_str() { - "plain" => BorderType::Plain, - "rounded" => BorderType::Rounded, - "double" => BorderType::Double, - "thick" => BorderType::Thick, - "quadrantoutside" => BorderType::QuadrantOutside, - "quadrantinside" => BorderType::QuadrantInside, - _ => { - return Err(Error::invalid_value( - Unexpected::Bytes(border_string.as_bytes()), - &"border string", - )); - } - }) -} - -impl Default for Theme { - fn default() -> Self { - Theme { - name: "Default".to_owned(), - bg: Color::Reset, - fg: Color::White, - border: BorderType::Plain, - border_color: Color::Gray, - border_focused_color: Color::LightCyan, - hl_bg: Color::DarkGray, - solid_bg: Color::White, - solid_fg: Color::Black, - info: Color::LightCyan, - warning: Color::Yellow, - success: Color::Green, - error: Color::Red, - source: Default::default(), - } - } -} - -impl Theme { - fn from_path(path: impl AsRef) -> Result> { - config::load_path(path) - } -} - -pub fn default_themes() -> IndexMap { - collection![ - "Default".to_owned() => Theme::default(), - "Dracula".to_owned() => Theme { - name: "Dracula".to_owned(), - bg: Color::Rgb(40, 42, 54), - fg: Color::Rgb(248, 248, 242), - border: BorderType::Rounded, - border_color: Color::Rgb(98, 114, 164), - border_focused_color: Color::Rgb(189, 147, 249), - hl_bg: Color::Rgb(98, 114, 164), - solid_fg: Color::Rgb(40, 42, 54), - solid_bg: Color::Rgb(139, 233, 253), - info: Color::Rgb(189, 147, 249), - warning: Color::Rgb(241, 250, 140), - success: Color::Rgb(80, 250, 123), - error: Color::Rgb(255, 85, 85), - source: Default::default(), - }, - "Gruvbox".to_owned() => Theme { - name: "Gruvbox".to_owned(), - bg: Color::Rgb(40, 40, 40), - fg: Color::Rgb(235, 219, 178), - border: BorderType::Plain, - border_color: Color::Rgb(102, 92, 84), - border_focused_color: Color::Rgb(214, 93, 14), - hl_bg: Color::Rgb(80, 73, 69), - solid_bg: Color::Rgb(69, 133, 136), - solid_fg: Color::Rgb(235, 219, 178), - info: Color::Rgb(214, 93, 14), - warning: Color::Rgb(250, 189, 47), - success: Color::Rgb(152, 151, 26), - error: Color::Rgb(204, 36, 29), - source: Default::default(), - }, - "Catppuccin Macchiato".to_owned() => Theme { - name: "Catppuccin Macchiato".to_owned(), - bg: Color::Rgb(24, 25, 38), - fg: Color::Rgb(202, 211, 245), - border: BorderType::Rounded, - border_color: Color::Rgb(110, 115, 141), - border_focused_color: Color::Rgb(125, 196, 228), - hl_bg: Color::Rgb(110, 115, 141), - solid_bg: Color::Rgb(166, 218, 149), - solid_fg: Color::Rgb(24, 25, 38), - info: Color::Rgb(125, 196, 228), - warning: Color::Rgb(238, 212, 159), - success: Color::Rgb(166, 218, 149), - error: Color::Rgb(237, 135, 150), - source: Default::default(), - }, - ] -} diff --git a/src/themes.rs b/src/themes.rs new file mode 100644 index 0000000..57d7a53 --- /dev/null +++ b/src/themes.rs @@ -0,0 +1 @@ +pub struct Theme {} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..e496018 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,205 @@ +use std::io::{stdout, Stdout}; +use std::ops::{Deref, DerefMut}; +use std::time::Duration; + +use color_eyre::Result; + +use crossterm::cursor; +use crossterm::event::{ + DisableBracketedPaste, EnableBracketedPaste, Event, EventStream, KeyEvent, KeyEventKind, + MouseEvent, +}; +use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; +use futures::{FutureExt as _, StreamExt as _}; +use ratatui::backend::CrosstermBackend as Backend; +use ratatui::Terminal; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::error; + +pub struct Tui { + pub terminal: Terminal>, + pub event_task: Option>, + pub cancel_token: CancellationToken, + pub event_rx: UnboundedReceiver, + pub event_tx: UnboundedSender, + pub frame_rate: f64, + pub tick_rate: f64, +} + +pub enum TuiEvent { + Init, // TODO: determine if necessary + FocusGained, + FocusLost, + // Quit, + Tick, + Render, + Key(KeyEvent), + Mouse(MouseEvent), + Resize(u16, u16), + Paste(String), + Error, +} + +impl Tui { + pub fn new() -> Result { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + Ok(Self { + terminal: Terminal::new(Backend::new(stdout()))?, + event_task: None, + cancel_token: CancellationToken::new(), + event_rx, + event_tx, + frame_rate: 60.0, + tick_rate: 4.0, + }) + } + + pub fn tick_rate(mut self, tick_rate: f64) -> Self { + self.tick_rate = tick_rate; + self + } + + pub fn frame_rate(mut self, frame_rate: f64) -> Self { + self.frame_rate = frame_rate; + self + } + + pub fn start(&mut self) { + self.cancel(); + self.cancel_token = CancellationToken::new(); + + let event_loop = Self::event_loop( + self.event_tx.clone(), + self.cancel_token.clone(), + self.tick_rate, + self.frame_rate, + ); + self.event_task = Some(tokio::spawn(async { + event_loop.await; + })); + } + + pub fn stop(&mut self) -> Result<()> { + self.cancel(); + let mut counter = 0; + if let Some(event_task) = self.event_task.as_ref() { + while !event_task.is_finished() { + std::thread::sleep(Duration::from_millis(1)); + counter += 1; + if counter > 50 { + event_task.abort(); + } + if counter > 100 { + error!("Failed to abort task in 100ms"); + } + } + } + Ok(()) + } + + async fn event_loop( + event_tx: UnboundedSender, + cancel_token: CancellationToken, + tick_rate: f64, + frame_rate: f64, + ) { + let mut event_stream = EventStream::new(); + let mut tick_interval = tokio::time::interval(Duration::from_secs_f64(1.0 / tick_rate)); + let mut frame_interval = tokio::time::interval(Duration::from_secs_f64(1.0 / frame_rate)); + + event_tx + .send(TuiEvent::Init) + .expect("Failed to send initialize event"); + + loop { + let event = tokio::select! { + _ = cancel_token.cancelled() => { + break; // Event loop cancelled + } + _ = tick_interval.tick() => TuiEvent::Tick, + _ = frame_interval.tick() => TuiEvent::Render, + crossterm_event = event_stream.next().fuse() => match crossterm_event { + Some(Ok(event)) => match event { + Event::Key(key) if key.kind == KeyEventKind::Press => TuiEvent::Key(key), + Event::Mouse(mouse) => TuiEvent::Mouse(mouse), + Event::Resize(x, y) => TuiEvent::Resize(x, y), + Event::FocusLost => TuiEvent::FocusLost, + Event::FocusGained => TuiEvent::FocusGained, + Event::Paste(s) => TuiEvent::Paste(s), + _ => continue, + } + Some(Err(_)) => TuiEvent::Error, + None => break, // Event stream terminated + } + }; + if event_tx.send(event).is_err() { + break; // receiver dropped + } + } + cancel_token.cancel(); + } + + pub fn cancel(&self) { + self.cancel_token.cancel(); + } + + pub fn enter(&mut self) -> Result<()> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!( + stdout(), + EnterAlternateScreen, + EnableBracketedPaste, + cursor::Hide + )?; + self.start(); + Ok(()) + } + + pub fn exit(&mut self) -> Result<()> { + self.stop()?; + if crossterm::terminal::is_raw_mode_enabled()? { + self.flush()?; + crossterm::execute!( + stdout(), + DisableBracketedPaste, + LeaveAlternateScreen, + cursor::Show + )?; + crossterm::terminal::disable_raw_mode()?; + } + Ok(()) + } + + pub fn suspend(&mut self) -> Result<()> { + self.exit()?; + #[cfg(not(windows))] + signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; + Ok(()) + } + + pub async fn next_event(&mut self) -> Option { + self.event_rx.recv().await + } +} + +impl Deref for Tui { + type Target = ratatui::Terminal>; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for Tui { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for Tui { + fn drop(&mut self) { + self.exit().unwrap(); + } +} diff --git a/src/util.rs b/src/util.rs deleted file mode 100644 index fcfd875..0000000 --- a/src/util.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod cmd; -pub mod colors; -pub mod conv; -pub mod html; -pub mod strings; -pub mod term; -pub mod types; diff --git a/src/util/cmd.rs b/src/util/cmd.rs deleted file mode 100644 index 0a33a85..0000000 --- a/src/util/cmd.rs +++ /dev/null @@ -1,64 +0,0 @@ -use std::{ - error::Error, - io::{BufReader, Read as _}, - process::{Command, Stdio}, -}; - -pub struct CommandBuilder { - cmd: String, -} - -impl CommandBuilder { - pub fn new(cmd: String) -> Self { - CommandBuilder { cmd } - } - - pub fn sub(&mut self, pattern: &str, sub: &str) -> &mut Self { - self.cmd = self.cmd.replace(pattern, sub); - self - } - - pub fn run>>(&self, shell: S) -> Result<(), Box> { - let shell = Into::>::into(shell).unwrap_or(Self::default_shell()); - let cmds = shell.split_whitespace().collect::>(); - if let [base_cmd, args @ ..] = cmds.as_slice() { - let cmd = Command::new(base_cmd) - .args(args) - .arg(&self.cmd) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn(); - - let child = match cmd { - Ok(child) => child, - Err(e) => return Err(format!("{}:\nFailed to run:\n{}", self.cmd, e).into()), - }; - let output = match child.wait_with_output() { - Ok(output) => output, - Err(e) => return Err(format!("{}:\nFailed to get output:\n{}", self.cmd, e).into()), - }; - - if output.status.code() != Some(0) { - let mut err = BufReader::new(&*output.stderr); - let mut err_str = String::new(); - err.read_to_string(&mut err_str).unwrap_or(0); - return Err(format!( - "{}:\nExited with status code {}:\n{}", - self.cmd, output.status, err_str - ) - .into()); - } - Ok(()) - } else { - Err(format!("Shell command is not properly formatted:\n{}", shell).into()) - } - } - - pub fn default_shell() -> String { - #[cfg(windows)] - return "powershell.exe -Command".to_owned(); - #[cfg(unix)] - return "sh -c".to_owned(); - } -} diff --git a/src/util/colors.rs b/src/util/colors.rs deleted file mode 100644 index 52bd80d..0000000 --- a/src/util/colors.rs +++ /dev/null @@ -1,111 +0,0 @@ -// Source: https://docs.rs/color-to-tui/0.3.0/src/color_to_tui -pub mod color_to_tui { - use ratatui::style::Color; - use serde::{Deserialize as _, Deserializer, Serializer}; - - pub fn serialize(color: &Color, serializer: S) -> Result { - serializer.serialize_str(&match color { - Color::Reset => "Reset".to_string(), - Color::Red => "Red".to_string(), - Color::Green => "Green".to_string(), - Color::Black => "Black".to_string(), - Color::Yellow => "Yellow".to_string(), - Color::Blue => "Blue".to_string(), - Color::Magenta => "Magenta".to_string(), - Color::Cyan => "Cyan".to_string(), - Color::Gray => "Gray".to_string(), - Color::White => "White".to_string(), - - Color::DarkGray => "DarkGray".to_string(), - Color::LightBlue => "LightBlue".to_string(), - Color::LightCyan => "LightCyan".to_string(), - Color::LightGreen => "LightGreen".to_string(), - Color::LightMagenta => "LightMagenta".to_string(), - Color::LightRed => "LightRed".to_string(), - Color::LightYellow => "LightYellow".to_string(), - Color::Indexed(index) => format!("{:03}", index), - Color::Rgb(r, g, b) => format!("#{:02X}{:02X}{:02X}", r, g, b), - }) - } - - pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { - use serde::de::{Error, Unexpected}; - - let color_string = String::deserialize(deserializer)?; - Ok(match color_string.to_lowercase().as_str() { - "reset" => Color::Reset, - "red" => Color::Red, - "green" => Color::Green, - "black" => Color::Black, - "yellow" => Color::Yellow, - "blue" => Color::Blue, - "magenta" => Color::Magenta, - "cyan" => Color::Cyan, - "gray" => Color::Gray, - "white" => Color::White, - - "darkgray" => Color::DarkGray, - "lightblue" => Color::LightBlue, - "lightcyan" => Color::LightCyan, - "lightgreen" => Color::LightGreen, - "lightmagenta" => Color::LightMagenta, - "lightred" => Color::LightRed, - "lightyellow" => Color::LightYellow, - _ => match color_string.len() { - 3 => { - let index = color_string.parse::(); - if let Ok(index) = index { - Color::Indexed(index) - } else { - return Err(Error::invalid_type( - Unexpected::Bytes(color_string.as_bytes()), - &"u8 index color", - )); - } - } - 4 | 7 => { - if !color_string.starts_with('#') { - return Err(Error::invalid_value( - Unexpected::Char(color_string.chars().next().unwrap()), - &"# at the start", - )); - } - - let color_string = color_string.trim_start_matches('#'); - - let (r, g, b); - - match color_string.len() { - 6 => { - r = u8::from_str_radix(&color_string[0..2], 16); - g = u8::from_str_radix(&color_string[2..4], 16); - b = u8::from_str_radix(&color_string[4..6], 16); - } - 3 => { - r = u8::from_str_radix(&color_string[0..1], 16).map(|r| r * 17); - g = u8::from_str_radix(&color_string[1..2], 16).map(|g| g * 17); - b = u8::from_str_radix(&color_string[2..3], 16).map(|b| b * 17); - } - _ => unreachable!("Can't be reached since already checked"), - } - - match (r, g, b) { - (Ok(r), Ok(g), Ok(b)) => Color::Rgb(r, g, b), - (_, _, _) => { - return Err(Error::invalid_value( - Unexpected::Bytes(color_string.as_bytes()), - &"hex color string", - )); - } - } - } - _ => { - return Err(serde::de::Error::invalid_length( - color_string.len(), - &"color string with length 4 or 7", - )) - } - }, - }) - } -} diff --git a/src/util/conv.rs b/src/util/conv.rs deleted file mode 100644 index ef95152..0000000 --- a/src/util/conv.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::error::Error; - -use chrono::{DateTime, Local, Utc}; -use crossterm::event::{KeyCode, KeyModifiers, MediaKeyCode, ModifierKeyCode}; -use reqwest::Url; - -static TIME_UNITS_LONG: [&str; 7] = [ - " year", " month", " week", " day", " hour", " minute", " second", -]; - -static TIME_UNITS_SHORT: [&str; 7] = ["y", "mo", "w", "d", "h", "m", "s"]; - -pub fn to_relative_date(time: DateTime, short: bool) -> String { - let delta = Utc::now().signed_duration_since(time); - let years = delta.num_days() / 365; - let months = delta.num_days() / 30 - (delta.num_days() / 365) * 12; - let weeks = delta.num_weeks() - ((delta.num_days() / 30) * 30) / 7; - let days = delta.num_days() - delta.num_weeks() * 7; - - let hours = delta.num_hours() - delta.num_days() * 24; - let minutes = delta.num_minutes() - delta.num_hours() * 60; - let seconds = delta.num_seconds() - delta.num_minutes() * 60; - - let (units, plural, sep, end) = if short { - (TIME_UNITS_SHORT, "", " ", "") - } else { - (TIME_UNITS_LONG, "s", ", ", " ago") - }; - - let time = [years, months, weeks, days, hours, minutes, seconds]; - - let rel_dates = time - .into_iter() - .zip(units) - .filter(|(amt, _)| amt.is_positive()) - .map(|(amt, unit)| { - if amt == 1 { - format!("{}{}", amt, unit) - } else { - format!("{}{}{}", amt, unit, plural) - } - }) - .take(2) - .collect::>(); - if !rel_dates.is_empty() { - format!("{}{}", rel_dates.join(sep), end) - } else { - "Now".to_string() - } -} - -pub fn get_hash(magnet: String) -> Option { - magnet - .split_once("xt=urn:btih:") - .and_then(|m| m.1.split_once('&').map(|m| m.0.to_owned())) -} - -pub fn add_protocol>( - url: S, - default_https: bool, -) -> Result> { - let url = url.into(); - if let Some((method, other)) = url.split_once(':') { - if matches!(method, "http" | "https" | "socks5") && matches!(other.get(..2), Some("//")) { - return Ok(url.parse::()?); - } - } - let protocol = match default_https { - true => "https", - false => "http", - }; - Ok(format!("{}://{}", protocol, url).parse::()?) -} - -pub fn to_bytes(size: &str) -> usize { - let mut split = size.split_whitespace(); - let f = split - .next() - .and_then(|b| b.parse::().ok()) - .unwrap_or(-1.); - let power = match split.last().and_then(|u| u.chars().next()) { - Some('T') => 4, - Some('G') => 3, - Some('M') => 2, - Some('K') => 1, - _ => 0, - }; - (1024_f64.powi(power) * f) as usize -} - -pub fn shorten_number(n: u32) -> String { - if n >= 10000 { - format!("{}K", n / 1000) - } else { - n.to_string() - } -} - -pub fn key_to_string(key: KeyCode, modifier: KeyModifiers) -> String { - let key = match key { - KeyCode::Backspace => "BS".to_owned(), - KeyCode::Enter => "CR".to_owned(), - KeyCode::Left => "Left".to_owned(), - KeyCode::Right => "Right".to_owned(), - KeyCode::Up => "Up".to_owned(), - KeyCode::Down => "Down".to_owned(), - KeyCode::Home => "Home".to_owned(), - KeyCode::End => "End".to_owned(), - KeyCode::PageUp => "PgUp".to_owned(), - KeyCode::PageDown => "PgDown".to_owned(), - KeyCode::Tab | KeyCode::BackTab => "Tab".to_owned(), - KeyCode::Delete => "Del".to_owned(), - KeyCode::Insert => "Ins".to_owned(), - KeyCode::F(f) => format!("F{}", f), - KeyCode::Char(' ') => "Space".to_owned(), - KeyCode::Char(c) => match modifier { - KeyModifiers::NONE | KeyModifiers::SHIFT => return c.to_string(), - _ => c.to_string(), - }, - KeyCode::Esc => "Esc".to_owned(), - KeyCode::Null => "Null".to_owned(), - KeyCode::CapsLock => "CapsLock".to_owned(), - KeyCode::ScrollLock => "ScrollLock".to_owned(), - KeyCode::NumLock => "NumLock".to_owned(), - KeyCode::PrintScreen => "Print".to_owned(), - KeyCode::Pause => "Pause".to_owned(), - KeyCode::Menu => "Menu".to_owned(), - KeyCode::KeypadBegin => "Begin".to_owned(), - KeyCode::Media(m) => match m { - MediaKeyCode::Play => "MediaPlay", - MediaKeyCode::Pause => "MediaPause", - MediaKeyCode::PlayPause => "MediaPlayPause", - MediaKeyCode::Reverse => "MediaReverse", - MediaKeyCode::Stop => "MediaStop", - MediaKeyCode::FastForward => "MediaFastForward", - MediaKeyCode::Rewind => "MediaRewind", - MediaKeyCode::TrackNext => "MediaTrackNext", - MediaKeyCode::TrackPrevious => "MediaTrackPrevious", - MediaKeyCode::Record => "MediaRecord", - MediaKeyCode::LowerVolume => "MediaLowerVolume", - MediaKeyCode::RaiseVolume => "MediaRaiseVolume", - MediaKeyCode::MuteVolume => "MediaMuteVolume", - } - .to_owned(), - KeyCode::Modifier(m) => match m { - ModifierKeyCode::LeftShift => "LeftShift", - ModifierKeyCode::LeftControl => "LeftControl", - ModifierKeyCode::LeftAlt => "LeftAlt", - ModifierKeyCode::LeftSuper => "LeftSuper", - ModifierKeyCode::LeftHyper => "LeftHyper", - ModifierKeyCode::LeftMeta => "LeftMeta", - ModifierKeyCode::RightShift => "RightShift", - ModifierKeyCode::RightControl => "RightControl", - ModifierKeyCode::RightAlt => "RightAlt", - ModifierKeyCode::RightSuper => "RightSuper", - ModifierKeyCode::RightHyper => "RightHyper", - ModifierKeyCode::RightMeta => "RightMeta", - ModifierKeyCode::IsoLevel3Shift => "IsoLevel3Shift", - ModifierKeyCode::IsoLevel5Shift => "IsoLevel5Shift", - } - .to_owned(), - }; - let modifier = match modifier { - KeyModifiers::CONTROL => "C-", - KeyModifiers::SHIFT => "S-", - KeyModifiers::ALT => "A-", - KeyModifiers::SUPER => "U-", - KeyModifiers::META => "M-", - KeyModifiers::HYPER => "H-", - _ => "", - }; - format!("<{}{}>", modifier, key) -} diff --git a/src/util/html.rs b/src/util/html.rs deleted file mode 100644 index 18e271e..0000000 --- a/src/util/html.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::str::FromStr; - -use scraper::{ElementRef, Selector}; - -pub fn as_type(s: String) -> Option { - s.chars() - .filter(char::is_ascii_digit) - .collect::() - .parse::() - .ok() -} - -pub fn inner(e: ElementRef, s: &Selector, default: &str) -> String { - e.select(s) - .next() - .map(|i| i.inner_html()) - .unwrap_or(default.to_owned()) -} - -pub fn attr(e: ElementRef, s: &Selector, attr: &str) -> String { - e.select(s) - .next() - .and_then(|i| i.value().attr(attr)) - .unwrap_or("") - .to_owned() -} diff --git a/src/util/strings.rs b/src/util/strings.rs deleted file mode 100644 index 7f5e800..0000000 --- a/src/util/strings.rs +++ /dev/null @@ -1,152 +0,0 @@ -use std::{collections::VecDeque, ops::RangeBounds}; - -use unicode_width::UnicodeWidthChar as _; - -pub fn pos_of_nth_char(s: &str, idx: usize) -> usize { - s.chars() - .take(idx) - .fold(0, |acc, c| acc + c.width().unwrap_or(0)) -} - -pub fn without_nth_char(s: &str, idx: usize) -> String { - s.chars() - .enumerate() - .filter_map(|(i, c)| if i != idx { Some(c) } else { None }) - .collect::() -} - -pub fn without_range(s: &str, range: impl RangeBounds) -> String { - let mut vec = s.chars().collect::>(); - vec.drain(range); - vec.into_iter().collect() -} - -pub fn insert_char(s: &str, idx: usize, x: char) -> String { - let mut vec = s.chars().collect::>(); - vec.insert(idx, x); - vec.into_iter().collect() -} - -pub fn replace_non_space_whitespace(s: &str) -> String { - s.chars() - .map(|c| { - if c.is_whitespace() && c != ' ' { - ' ' - } else { - c - } - }) - .collect() -} - -pub fn truncate_ellipsis( - s: String, - n: usize, - padding: usize, - cursor: usize, - offset: &mut usize, -) -> (Option, String, Option) { - let (mut sum, mut before) = (0, 0); - let total_width = s.chars().fold(0, |acc, c| acc + c.width().unwrap_or(0)); - let cursor_pad_right = (cursor + padding).min(total_width); - - // ----o------------------c-p-x - if cursor_pad_right >= *offset + n { - *offset = cursor_pad_right.saturating_sub(n) + 1; - } else if cursor.saturating_sub(padding) <= *offset { - *offset = cursor.saturating_sub(padding + 1); - } - - let mut chars = s - .chars() - // Skip `offset` number of columns - .skip_while(|x| { - let add = before + x.width().unwrap_or(0); - let res = add <= *offset; - if res { - before = add; - } - res - }) - // Collect `n` number of columns - .take_while(|x| { - let add = sum + x.width().unwrap_or(0); - let res = add <= n; - if res { - sum = add; - } - res - }) - .collect::>(); - - // Gap left by cut-off wide characters - let gap = offset.saturating_sub(before); - - // Show ellipsis if characters are hidden to the left - let el = (*offset > 0).then(|| { - // Remove first (visible) character, replace with ellipsis - let repeat = chars - .pop_front() - .and_then(|c| c.width()) - .unwrap_or(0) - .saturating_sub(gap); - ['…'].repeat(repeat).iter().collect() - }); - - let gap = n.saturating_sub(sum) + gap; - - // Show ellipsis if characters are hidden to the right - let er = (*offset + n < total_width + 1).then(|| { - // Remove last (visible) character, replace with ellipsis - let repeat = if gap > 0 { - gap - } else { - // Only pop last char if no gap - chars.pop_back().and_then(|c| c.width()).unwrap_or(0) - }; - ['…'].repeat(repeat).iter().collect() - }); - - return (el, chars.iter().collect::(), er); -} - -pub fn back_word(input: &str, start: usize) -> usize { - let cursor = start.min(input.chars().count()); - // Find the first non-space character before the cursor - let first_non_space = input - .chars() - .take(cursor) - .collect::>() - .into_iter() - .rposition(|c| c != ' ') - .unwrap_or(0); - - // Find the first space character before the first non-space character - input - .chars() - .take(first_non_space) - .collect::>() - .into_iter() - .rposition(|c| c == ' ') - .map(|u| u + 1) - .unwrap_or(0) -} - -pub fn forward_word(input: &str, start: usize) -> usize { - let idx = start.min(input.chars().count()); - - // Skip all non-whitespace - let nonws = input - .chars() - .skip(idx) - .position(|c| c.is_whitespace()) - .map(|n| n + idx) - .unwrap_or(input.chars().count()); - // Then skip all whitespace, starting from last non-whitespace - input - .chars() - .skip(nonws) - .position(|c| !c.is_whitespace()) - .map(|n| n + nonws) - .unwrap_or(input.chars().count()) -} diff --git a/src/util/term.rs b/src/util/term.rs deleted file mode 100644 index 5b17064..0000000 --- a/src/util/term.rs +++ /dev/null @@ -1,59 +0,0 @@ -use std::io::{self, stdout}; - -use crossterm::{ - cursor::SetCursorStyle, - event::{DisableBracketedPaste, EnableBracketedPaste}, - terminal::{EnterAlternateScreen, LeaveAlternateScreen}, - ExecutableCommand as _, -}; - -#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] -use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; - -#[cfg(unix)] -use nix::{ - sys::signal::{self, Signal}, - unistd::Pid, -}; -#[cfg(unix)] -use ratatui::{backend::Backend, Terminal}; -#[cfg(unix)] -use std::error::Error; - -pub fn setup_terminal() -> io::Result<()> { - #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] - enable_raw_mode()?; - stdout().execute(EnableBracketedPaste)?; - stdout().execute(EnterAlternateScreen)?; - stdout().execute(SetCursorStyle::SteadyBar)?; - Ok(()) -} - -pub fn reset_terminal() -> io::Result<()> { - #[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))] - disable_raw_mode()?; - stdout().execute(SetCursorStyle::DefaultUserShape)?; - stdout().execute(LeaveAlternateScreen)?; - stdout().execute(DisableBracketedPaste)?; - Ok(()) -} - -#[cfg(unix)] -pub fn suspend_self(terminal: &mut Terminal) -> Result<(), Box> { - // Make sure cursor is drawn - - terminal.draw(|f| f.set_cursor_position((0, 0)))?; - - reset_terminal()?; - - signal::kill(Pid::from_raw(std::process::id() as i32), Signal::SIGTSTP)?; - Ok(()) -} - -#[cfg(unix)] -pub fn continue_self(terminal: &mut Terminal) -> Result<(), Box> { - setup_terminal()?; - - Terminal::clear(terminal)?; - Ok(()) -} diff --git a/src/util/types.rs b/src/util/types.rs deleted file mode 100644 index 505db2a..0000000 --- a/src/util/types.rs +++ /dev/null @@ -1,24 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Clone)] -#[serde(untagged)] -pub enum OneOrMany { - One(T), - Many(Vec), -} - -impl OneOrMany { - pub fn vec(self) -> Vec { - match self { - OneOrMany::One(one) => vec![one], - OneOrMany::Many(many) => many, - } - } -} - -#[derive(Serialize, Deserialize)] -#[serde(untagged)] -pub enum Either { - Left(A), - Right(B), -} diff --git a/src/widget.rs b/src/widget.rs deleted file mode 100644 index a7d5515..0000000 --- a/src/widget.rs +++ /dev/null @@ -1,291 +0,0 @@ -use std::{cmp::min, slice::Iter}; - -use crossterm::event::Event; -use ratatui::{ - buffer::Buffer, - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Stylize as _}, - widgets::{ - Block, Borders, Clear, Scrollbar, ScrollbarOrientation, ScrollbarState, TableState, - Widget as _, - }, - Frame, -}; -use serde::{Deserialize, Serialize}; -use unicode_width::UnicodeWidthStr as _; - -use crate::{app::Context, style, theme::Theme}; - -#[cfg(feature = "captcha")] -pub mod captcha; - -pub mod batch; -pub mod category; -pub mod clients; -pub mod filter; -pub mod help; -pub mod input; -pub mod notifications; -pub mod notify_box; -pub mod page; -pub mod results; -pub mod search; -pub mod sort; -pub mod sources; -pub mod themes; -pub mod user; - -pub trait Widget { - fn draw(&mut self, buf: &mut Frame, ctx: &Context, area: Rect); - fn handle_event(&mut self, app: &mut Context, e: &Event); - fn get_help() -> Option>; -} - -pub trait EnumIter { - fn iter() -> Iter<'static, T>; -} - -#[derive(Serialize, Deserialize, Clone, Copy)] -pub enum Corner { - TopLeft, - TopRight, - BottomLeft, - BottomRight, -} - -impl Corner { - //pub fn try_title<'a, L: Into>>( - // self, - // text: L, - // area: Rect, - // hide_if_too_small: bool, - //) -> Option<(Line<'a>, Rect)> { - // let line: Line = text.into(); - // let line_width = min(area.width, line.width() as u16); - // if hide_if_too_small && area.width < line.width() as u16 + 2 { - // // Too small - // return None; - // } - // let (left, y) = match self { - // Corner::TopLeft => (area.left() + 1, area.top()), - // Corner::TopRight => (area.right() - 1 - line_width, area.top()), - // Corner::BottomLeft => (area.left() + 1, area.bottom() - 1), - // Corner::BottomRight => (area.right() - 1 - line_width, area.bottom() - 1), - // }; - // let right = Rect::new(left, y, line_width, 1); - // Some((line, right)) - //} -} - -pub fn scroll_padding( - selected: usize, - height: usize, - header_height: usize, - num_items: usize, - amt: usize, - offset: &mut usize, -) { - let first_row = *offset; - let last_row = *offset + height; - if selected + 1 == first_row + amt && selected >= amt { - *offset -= 1; - } - if selected + amt + header_height == last_row && selected + amt != num_items { - *offset += 1; - } -} - -pub fn dim_buffer(area: Rect, buf: &mut Buffer, amt: f32) { - for r in area.top()..area.bottom() { - for c in area.left()..area.right() { - if let Some(cell) = buf.cell_mut((c, r)) { - if let Color::Rgb(r, g, b) = cell.fg { - let r = (r as f32 * amt) as u8; - let g = (g as f32 * amt) as u8; - let b = (b as f32 * amt) as u8; - cell.fg = Color::Rgb(r, g, b); - } - if let Color::Rgb(r, g, b) = cell.bg { - let r = (r as f32 * amt) as u8; - let g = (g as f32 * amt) as u8; - let b = (b as f32 * amt) as u8; - cell.bg = Color::Rgb(r, g, b); - } - } - } - } -} - -pub fn centered_rect(mut x_len: u16, mut y_len: u16, r: Rect) -> Rect { - x_len = min(x_len, r.width); - y_len = min(y_len, r.height); - let popup_layout = Layout::new( - Direction::Vertical, - [ - Constraint::Length((r.height - y_len) / 2), - Constraint::Length(y_len), - Constraint::Length((r.height - y_len) / 2), - ], - ) - .split(r); - - Layout::new( - Direction::Horizontal, - [ - Constraint::Length((r.width - x_len) / 2), - Constraint::Length(x_len), - Constraint::Length((r.width - x_len) / 2), - ], - ) - .split(popup_layout[1])[1] -} - -pub fn border_block(theme: &Theme, focused: bool) -> Block { - Block::new() - .border_style(match focused { - true => style!(fg:theme.border_focused_color), - false => style!(fg:theme.border_color), - }) - .bg(theme.bg) - .fg(theme.fg) - .borders(Borders::ALL) - .border_type(theme.border) -} - -pub fn scrollbar(ctx: &Context, orientation: ScrollbarOrientation) -> Scrollbar<'_> { - let set = ctx.theme.border.to_border_set(); - let track = match orientation { - ScrollbarOrientation::VerticalRight => set.vertical_right, - ScrollbarOrientation::VerticalLeft => set.vertical_left, - ScrollbarOrientation::HorizontalBottom => set.horizontal_bottom, - ScrollbarOrientation::HorizontalTop => set.horizontal_top, - }; - Scrollbar::default() - .orientation(orientation) - .track_symbol(Some(track)) - .begin_symbol(None) - .end_symbol(None) -} - -pub fn clear(area: Rect, buf: &mut Buffer, fill: Color) { - // Deal with wide chars which might extend too far - if area.left() > 0 && buf.area.contains((area.left() - 1, area.top()).into()) { - for i in area.top()..area.bottom() { - if let Some(c) = buf.cell_mut((area.left() - 1, i)) { - if c.symbol().width() > 1 { - c.set_char(' '); - } - } - } - } - Clear.render(area, buf); - Block::new().bg(fill).render(area, buf); -} - -pub struct StatefulTable { - pub state: TableState, - pub scrollbar_state: ScrollbarState, - pub items: Vec, -} - -impl StatefulTable { - pub fn new(items: &[T]) -> StatefulTable { - StatefulTable { - state: TableState::default().with_selected(0), - scrollbar_state: ScrollbarState::default(), - items: items.to_vec(), - } - } - - pub fn empty() -> StatefulTable { - StatefulTable { - state: TableState::default().with_selected(0), - scrollbar_state: ScrollbarState::default(), - items: vec![], - } - } - - pub fn next_wrap(&mut self, amt: isize) { - if self.items.is_empty() { - return; - } - let i = match self.state.selected() { - Some(i) => (i as isize + amt).rem_euclid(self.items.len() as isize), - None => 0, - }; - self.state.select(Some(i as usize)); - self.scrollbar_state = self.scrollbar_state.position(i as usize); - } - - // pub fn next(&mut self, amt: isize) { - // if self.items.is_empty() { - // return; - // } - // let i = match self.state.selected() { - // Some(i) => i as isize + amt, - // None => 0, - // }; - // let idx = i.max(0).min(self.items.len() as isize - 1) as usize; - // self.state.select(Some(idx)); - // self.scrollbar_state = self.scrollbar_state.position(idx); - // } - - pub fn select(&mut self, idx: usize) { - self.state.select(Some(idx)); - self.scrollbar_state = self.scrollbar_state.position(idx); - } - - pub fn selected(&self) -> Option<&T> { - self.state.selected().and_then(|i| self.items.get(i)) - } -} - -#[derive(Default)] -pub struct VirtualStatefulTable { - pub state: TableState, - pub scrollbar_state: ScrollbarState, -} - -impl VirtualStatefulTable { - pub fn new() -> VirtualStatefulTable { - VirtualStatefulTable { - state: TableState::default().with_selected(0), - scrollbar_state: ScrollbarState::default(), - } - } - - pub fn next_wrap(&mut self, length: usize, amt: isize) -> usize { - if length == 0 { - return 0; - } - let i = match self.state.selected() { - Some(i) => (i as isize + amt).rem_euclid(length as isize), - None => 0, - } as usize; - self.state.select(Some(i)); - self.scrollbar_state = self.scrollbar_state.position(i); - i - } - - pub fn next(&mut self, length: usize, amt: isize) -> usize { - if length == 0 { - return 0; - } - let idx = match self.state.selected() { - Some(i) => i.saturating_add_signed(amt).min(length.saturating_sub(1)), - None => 0, - }; - self.state.select(Some(idx)); - self.scrollbar_state = self.scrollbar_state.position(idx); - idx - } - - pub fn select(&mut self, idx: usize) { - self.state.select(Some(idx)); - self.scrollbar_state = self.scrollbar_state.position(idx); - } - - pub fn selected(&self) -> Option { - self.state.selected() - } -} diff --git a/src/widget/batch.rs b/src/widget/batch.rs deleted file mode 100644 index 0e2ef03..0000000 --- a/src/widget/batch.rs +++ /dev/null @@ -1,172 +0,0 @@ -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use human_bytes::human_bytes; -use ratatui::{ - layout::{Constraint, Margin, Rect}, - style::{Style, Stylize}, - text::Line, - widgets::{Clear, Row, ScrollbarOrientation, StatefulWidget, Table, Widget}, - Frame, -}; - -use crate::{ - app::{Context, LoadType, Mode}, - source::ItemType, - title, -}; - -use super::{border_block, VirtualStatefulTable}; - -pub struct BatchWidget { - table: VirtualStatefulTable, -} - -impl Default for BatchWidget { - fn default() -> Self { - BatchWidget { - table: VirtualStatefulTable::new(), - } - } -} - -impl super::Widget for BatchWidget { - fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - let buf = f.buffer_mut(); - - let size = human_bytes(ctx.batch.iter().fold(0, |acc, i| acc + i.bytes) as f64); - let right_str = title!("Size({}): {}", ctx.batch.len(), size); - let block = border_block(&ctx.theme, ctx.mode == Mode::Batch) - .title(title!("Batch")) - .title_top(Line::from(right_str).right_aligned()); - - let focus_color = match ctx.mode { - Mode::Batch => ctx.theme.border_focused_color, - _ => ctx.theme.border_color, - }; - let rows = ctx - .batch - .iter() - .map(|i| { - Row::new([ - i.icon.label.fg((i.icon.color)(&ctx.theme)), - i.title.to_owned().fg(match i.item_type { - ItemType::Trusted => ctx.theme.success, - ItemType::Remake => ctx.theme.error, - ItemType::None => ctx.theme.fg, - }), - format!("{:>9}", i.size).fg(ctx.theme.fg), - ]) - }) - .collect::>(); - - let header = ["Cat", "Name", " Size"]; - let header = Row::new(header) - .fg(focus_color) - .underlined() - .height(1) - .bottom_margin(0); - let table = Table::new( - rows.to_owned(), - [ - Constraint::Length(3), - Constraint::Min(1), - Constraint::Length(9), - ], - ) - .block(block) - .header(header) - .highlight_style(Style::default().bg(ctx.theme.hl_bg)); - Clear.render(area, buf); - - let num_items = rows.len(); - super::scroll_padding( - self.table.selected().unwrap_or(0), - area.height as usize, - 3, - num_items, - ctx.config.scroll_padding, - self.table.state.offset_mut(), - ); - - StatefulWidget::render(table, area, buf, &mut self.table.state); - if ctx.batch.len() + 2 > area.height as usize { - let sb = super::scrollbar(ctx, ScrollbarOrientation::VerticalRight); - let sb_area = area.inner(Margin { - vertical: 1, - horizontal: 0, - }); - StatefulWidget::render( - sb, - sb_area, - buf, - &mut self.table.scrollbar_state.content_length(rows.len()), - ); - } - } - - fn handle_event(&mut self, ctx: &mut Context, evt: &Event) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - modifiers, - .. - }) = evt - { - use KeyCode::*; - match (code, modifiers) { - (Esc | Tab | BackTab, _) => { - ctx.mode = Mode::Normal; - } - (Char('q'), &KeyModifiers::NONE) => { - ctx.quit(); - } - (Char('j') | Down, &KeyModifiers::NONE) => { - self.table.next(ctx.batch.len(), 1); - } - (Char('k') | Up, &KeyModifiers::NONE) => { - self.table.next(ctx.batch.len(), -1); - } - (Char('J'), &KeyModifiers::SHIFT) => { - self.table.next(ctx.batch.len(), 4); - } - (Char('K'), &KeyModifiers::SHIFT) => { - self.table.next(ctx.batch.len(), -4); - } - (Char('g'), &KeyModifiers::NONE) => { - self.table.select(0); - } - (Char('G'), &KeyModifiers::SHIFT) => { - self.table.select(ctx.batch.len() - 1); - } - (Char(' '), &KeyModifiers::NONE) => { - if let Some(i) = self.table.selected() { - self.table.next(ctx.batch.len(), 0); - ctx.batch.remove(i); - self.table.next(ctx.batch.len(), 0); - } - } - (Char('a'), &KeyModifiers::CONTROL) => { - ctx.mode = Mode::Loading(LoadType::Batching); - } - (Char('x'), &KeyModifiers::CONTROL) => { - ctx.batch.clear(); - } - _ => {} - }; - } - } - - fn get_help() -> Option> { - Some(vec![ - ("Enter", "Download single torrent"), - ("Ctrl-A", "Download all torrents"), - ("Ctrl-X", "Clear batch"), - ("Esc/Tab/Shift-Tab", "Back to results"), - ("q", "Exit app"), - ("g/G", "Goto Top/Bottom"), - ("k, ↑", "Up"), - ("j, ↓", "Down"), - ("K, J", "Up/Down 4 items"), - ("Space", "Toggle item for batch download"), - ]) - } -} diff --git a/src/widget/captcha.rs b/src/widget/captcha.rs deleted file mode 100644 index 40aa5a5..0000000 --- a/src/widget/captcha.rs +++ /dev/null @@ -1,96 +0,0 @@ -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}; -use ratatui::{ - layout::{Constraint, Direction, Layout, Margin, Rect}, - widgets::StatefulWidget as _, - Frame, -}; -use ratatui_image::{protocol::StatefulProtocol, StatefulImage}; - -use crate::app::{Context, LoadType, Mode}; - -use super::{input::InputWidget, Widget}; - -pub struct CaptchaPopup { - pub image: Option>, - pub input: InputWidget, -} - -impl Default for CaptchaPopup { - fn default() -> Self { - Self { - image: None, - input: InputWidget::new(32, None), - } - } -} - -impl Widget for CaptchaPopup { - fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - let center = area.inner(Margin { - horizontal: 4, - vertical: 4, - }); - super::clear(center, f.buffer_mut(), ctx.theme.bg); - let layout = Layout::new( - Direction::Vertical, - [Constraint::Fill(1), Constraint::Length(3)], - ) - .split(center); - if let Some(img) = self.image.as_mut() { - f.render_widget( - super::border_block(&ctx.theme, true).title("Captcha"), - layout[0], - ); - StatefulImage::new(None).render( - layout[0].inner(Margin { - horizontal: 1, - vertical: 1, - }), - f.buffer_mut(), - img, - ); - } - f.render_widget( - super::border_block(&ctx.theme, true).title("Enter Captcha solution"), - layout[1], - ); - - let input_area = layout[1].inner(Margin { - horizontal: 1, - vertical: 1, - }); - self.input.draw(f, ctx, input_area); - self.input.show_cursor(f, input_area); - } - - fn handle_event(&mut self, ctx: &mut Context, e: &Event) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - .. - }) = e - { - match code { - KeyCode::Esc => { - ctx.mode = Mode::Normal; - } - KeyCode::Enter => { - ctx.mode = Mode::Loading(LoadType::SolvingCaptcha(self.input.input.clone())); - } - _ => {} - } - } - self.input.handle_event(ctx, e); - } - - fn get_help() -> Option> { - Some(vec![ - ("Enter", "Confirm"), - ("Esc, f, q", "Close"), - ("g", "Top"), - ("G", "Bottom"), - ("j, ↓", "Down"), - ("k, ↑", "Up"), - ]) - } -} diff --git a/src/widget/category.rs b/src/widget/category.rs deleted file mode 100644 index 59715c0..0000000 --- a/src/widget/category.rs +++ /dev/null @@ -1,237 +0,0 @@ -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}; -use ratatui::{ - layout::{Constraint, Margin, Rect}, - style::{Color, Style, Stylize as _}, - text::{Line, Text}, - widgets::{Row, ScrollbarOrientation, StatefulWidget, Table}, - Frame, -}; - -use crate::{ - app::{Context, LoadType, Mode}, - style, - theme::Theme, - title, -}; - -use super::{border_block, VirtualStatefulTable, Widget}; - -#[derive(Clone)] -pub struct CatEntry { - pub name: String, - pub cfg: String, - pub id: usize, - pub icon: CatIcon, -} - -#[derive(Clone)] -pub struct CatIcon { - pub label: &'static str, - pub color: fn(&Theme) -> Color, -} - -impl Default for CatIcon { - fn default() -> Self { - CatIcon { - label: "???", - color: |t: &Theme| t.fg, - } - } -} - -impl CatEntry { - pub fn new( - name: &str, - cfg: &str, - id: usize, - label: &'static str, - color: fn(&Theme) -> Color, - ) -> Self { - CatEntry { - name: name.to_string(), - cfg: cfg.to_string(), - id, - icon: CatIcon { label, color }, - } - } -} - -#[derive(Clone)] -pub struct CatStruct { - pub name: String, - pub entries: Vec, -} - -#[derive(Default)] -pub struct CategoryPopup { - pub selected: usize, - pub major: usize, - pub minor: usize, - pub table: VirtualStatefulTable, -} - -impl CategoryPopup { - fn next_tab(&mut self, max_cat: usize) { - self.major = match self.major + 1 >= max_cat { - true => 0, - false => self.major + 1, - }; - self.minor = 0; - if self.table.state.offset() > self.major { - *self.table.state.offset_mut() = self.major; - } - } - - fn prev_tab(&mut self, max_cat: usize) { - self.major = match self.major == 0 { - true => max_cat - 1, - false => self.major - 1, - }; - self.minor = 0; - if self.table.state.offset() > self.major { - *self.table.state.offset_mut() = self.major; - } - } -} - -impl Widget for CategoryPopup { - fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - if let Some(cat) = ctx.src_info.cats.get(self.major) { - let mut tbl: Vec = ctx - .src_info - .cats - .iter() - .enumerate() - .map(|(i, e)| match i == self.major { - false => Row::new(Text::raw(format!(" ▶ {}", e.name))), - true => Row::new(Text::raw(format!(" ▼ {}", e.name))) - .style(style!(bg:ctx.theme.solid_bg, fg:ctx.theme.solid_fg)), - }) - .collect(); - - let cat_rows = cat.entries.iter().map(|e| { - Row::new(vec![Line::from(vec![ - match e.id == self.selected { - true => "  ", - false => " ", - } - .into(), - e.icon.label.fg((e.icon.color)(&ctx.theme)), - " ".into(), - e.name.to_owned().into(), - ])]) - }); - let num_items = cat.entries.len() + ctx.src_info.cats.len(); - self.table.scrollbar_state = self.table.scrollbar_state.content_length(num_items); - - tbl.splice(self.major + 1..self.major + 1, cat_rows); - - let center = super::centered_rect(33, 14, area); - - super::scroll_padding( - self.table.selected().unwrap_or(0), - center.height as usize, - 2, - num_items, - 1, - self.table.state.offset_mut(), - ); - - super::clear(center, f.buffer_mut(), ctx.theme.bg); - let table = Table::new(tbl, [Constraint::Percentage(100)]) - .block(border_block(&ctx.theme, true).title(title!("Category"))) - .highlight_style(Style::default().bg(ctx.theme.hl_bg)); - StatefulWidget::render(table, center, f.buffer_mut(), &mut self.table.state); - - let sb = super::scrollbar(ctx, ScrollbarOrientation::VerticalRight); - let sb_area = center.inner(Margin { - vertical: 1, - horizontal: 0, - }); - StatefulWidget::render(sb, sb_area, f.buffer_mut(), &mut self.table.scrollbar_state); - } - } - - fn handle_event(&mut self, ctx: &mut Context, e: &Event) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - .. - }) = e - { - match code { - KeyCode::Enter => { - if let Some(cat) = ctx.src_info.cats.get(self.major) { - if let Some(item) = cat.entries.get(self.minor) { - self.selected = item.id; - ctx.notify_info(format!("Category \"{}\"", item.name)); - } - } - ctx.mode = Mode::Loading(LoadType::Categorizing); - } - KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => { - ctx.mode = Mode::Normal; - } - KeyCode::Char('j') | KeyCode::Down => { - if let Some(cat) = ctx.src_info.cats.get(self.major) { - self.minor = match self.minor + 1 >= cat.entries.len() { - true => { - self.next_tab(ctx.src_info.cats.len()); - 0 - } - false => self.minor + 1, - }; - self.table.select(self.major + self.minor + 1); - } - } - KeyCode::Char('k') | KeyCode::Up => { - if ctx.src_info.cats.get(self.major).is_some() { - self.minor = match self.minor < 1 { - true => { - self.prev_tab(ctx.src_info.cats.len()); - match ctx.src_info.cats.get(self.major) { - Some(cat) => cat.entries.len() - 1, - None => 0, - } - } - false => self.minor - 1, - }; - self.table.select(self.major + self.minor + 1); - } - } - KeyCode::Char('G') => { - if let Some(cat) = ctx.src_info.cats.get(self.major) { - self.minor = cat.entries.len() - 1; - self.table.select(self.major + self.minor + 1); - } - } - KeyCode::Char('g') => { - self.minor = 0; - self.table.select(self.major + self.minor + 1); - } - KeyCode::Tab | KeyCode::Char('J') => { - self.next_tab(ctx.src_info.cats.len()); - self.table.select(self.major + self.minor + 1); - } - KeyCode::BackTab | KeyCode::Char('K') => { - self.prev_tab(ctx.src_info.cats.len()); - self.table.select(self.major + self.minor + 1); - } - _ => {} - } - } - } - - fn get_help() -> Option> { - Some(vec![ - ("Enter", "Confirm"), - ("Esc, c, q", "Close"), - ("j, ↓", "Down"), - ("k, ↑", "Up"), - ("g", "Top"), - ("G", "Bottom"), - ("Tab, J", "Next Tab"), - ("S-Tab, K", "Prev Tab"), - ]) - } -} diff --git a/src/widget/clients.rs b/src/widget/clients.rs deleted file mode 100644 index 262b783..0000000 --- a/src/widget/clients.rs +++ /dev/null @@ -1,99 +0,0 @@ -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}; -use ratatui::{ - layout::{Constraint, Rect}, - widgets::{Row, StatefulWidget as _, Table}, - Frame, -}; -use strum::VariantArray; - -use crate::{ - app::{Context, Mode}, - client::Client, - style, title, -}; - -use super::{border_block, StatefulTable, Widget}; - -pub struct ClientsPopup { - pub table: StatefulTable, -} - -impl Default for ClientsPopup { - fn default() -> Self { - ClientsPopup { - table: StatefulTable::new(Client::VARIANTS), - } - } -} - -impl Widget for ClientsPopup { - fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - let buf = f.buffer_mut(); - let center = super::centered_rect(30, self.table.items.len() as u16 + 2, area); - let items = self.table.items.iter().map(|item| { - Row::new(vec![match item == &ctx.client { - true => format!("  {}", item), - false => format!(" {}", item), - }]) - }); - super::clear(center, buf, ctx.theme.bg); - let table = Table::new(items, [Constraint::Percentage(100)]) - .block(border_block(&ctx.theme, true).title(title!("Download Client"))) - .highlight_style(style!(bg:ctx.theme.hl_bg)); - table.render(center, buf, &mut self.table.state); - } - - fn handle_event(&mut self, ctx: &mut Context, e: &Event) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - .. - }) = e - { - match code { - KeyCode::Esc | KeyCode::Char('d') | KeyCode::Char('q') => { - ctx.mode = Mode::Normal; - } - KeyCode::Char('j') | KeyCode::Down => { - self.table.next_wrap(1); - } - KeyCode::Char('k') | KeyCode::Up => { - self.table.next_wrap(-1); - } - KeyCode::Char('G') => { - self.table.select(self.table.items.len() - 1); - } - KeyCode::Char('g') => { - self.table.select(0); - } - KeyCode::Enter => { - if let Some(c) = self.table.selected() { - ctx.client = *c; - ctx.config.download_client = *c; - - c.load_config(&mut ctx.config.client); - match ctx.save_config() { - Ok(_) => { - ctx.notify_info(format!("Updated download client to \"{}\"", c)) - } - Err(e) => ctx.notify_error(format!("Failed to update config:\n{}", e)), - } - ctx.mode = Mode::Normal; - } - } - _ => {} - } - } - } - - fn get_help() -> Option> { - Some(vec![ - ("Enter", "Confirm"), - ("Esc, d, q", "Close"), - ("j, ↓", "Down"), - ("k, ↑", "Up"), - ("g", "Top"), - ("G", "Bottom"), - ]) - } -} diff --git a/src/widget/filter.rs b/src/widget/filter.rs deleted file mode 100644 index d83285d..0000000 --- a/src/widget/filter.rs +++ /dev/null @@ -1,96 +0,0 @@ -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}; -use ratatui::{ - layout::{Constraint, Rect}, - widgets::{Row, StatefulWidget as _, Table}, - Frame, -}; - -use crate::{ - app::{Context, LoadType, Mode}, - style, title, -}; - -use super::{border_block, VirtualStatefulTable, Widget}; - -pub struct FilterPopup { - pub table: VirtualStatefulTable, - pub selected: usize, -} - -impl Default for FilterPopup { - fn default() -> Self { - FilterPopup { - table: VirtualStatefulTable::new(), - selected: 0, - } - } -} - -impl Widget for FilterPopup { - fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - let center = super::centered_rect(30, ctx.src_info.filters.len() as u16 + 2, area); - let items = - ctx.src_info - .filters - .iter() - .enumerate() - .map(|(i, item)| match i == self.selected { - true => Row::new(vec![format!("  {}", item.to_owned())]), - false => Row::new(vec![format!(" {}", item.to_owned())]), - }); - // super::dim_buffer(area, f.buffer_mut(), 0.5); - super::clear(center, f.buffer_mut(), ctx.theme.bg); - Table::new(items, [Constraint::Percentage(100)]) - .block(border_block(&ctx.theme, true).title(title!("Filter"))) - .highlight_style(style!(bg:ctx.theme.hl_bg)) - .render(center, f.buffer_mut(), &mut self.table.state); - } - - fn handle_event(&mut self, ctx: &mut Context, e: &Event) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - .. - }) = e - { - match code { - KeyCode::Esc | KeyCode::Char('f') | KeyCode::Char('q') => { - ctx.mode = Mode::Normal; - } - KeyCode::Char('j') | KeyCode::Down => { - self.table.next_wrap(ctx.src_info.filters.len(), 1); - } - KeyCode::Char('k') | KeyCode::Up => { - self.table.next_wrap(ctx.src_info.filters.len(), -1); - } - KeyCode::Char('G') => { - self.table.select(ctx.src_info.filters.len() - 1); - } - KeyCode::Char('g') => { - self.table.select(0); - } - KeyCode::Enter => { - if let Some(i) = self.table.state.selected() { - self.selected = i; - ctx.mode = Mode::Loading(LoadType::Filtering); - if let Some(f) = ctx.src_info.filters.get(i) { - ctx.notify_info(format!("Filter by \"{}\"", f)); - } - } - } - _ => {} - } - } - } - - fn get_help() -> Option> { - Some(vec![ - ("Enter", "Confirm"), - ("Esc, f, q", "Close"), - ("g", "Top"), - ("G", "Bottom"), - ("j, ↓", "Down"), - ("k, ↑", "Up"), - ]) - } -} diff --git a/src/widget/help.rs b/src/widget/help.rs deleted file mode 100644 index 7dc6b15..0000000 --- a/src/widget/help.rs +++ /dev/null @@ -1,134 +0,0 @@ -use std::cmp::{max, min}; - -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}; -use ratatui::{ - layout::{Alignment, Constraint, Margin, Rect}, - text::Line, - widgets::{Row, ScrollbarOrientation, StatefulWidget as _, Table}, - Frame, -}; - -use crate::{ - app::{Context, Mode}, - style, title, -}; - -use super::{border_block, StatefulTable, Widget}; - -pub struct HelpPopup { - pub table: StatefulTable<(&'static str, &'static str)>, - pub prev_mode: Mode, -} - -impl Default for HelpPopup { - fn default() -> Self { - HelpPopup { - table: StatefulTable::empty(), - prev_mode: Mode::Normal, - } - } -} - -impl HelpPopup { - pub fn with_items(&mut self, items: Vec<(&'static str, &'static str)>, prev_mode: Mode) { - self.table.scrollbar_state = self.table.scrollbar_state.content_length(items.len()); - self.table.items = items; - self.prev_mode = prev_mode; - } -} - -impl Widget for HelpPopup { - fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - let buf = f.buffer_mut(); - let max_size = 35; - - // Get max len of Key and Action - let (key_max, map_max) = self.table.items.iter().fold((15, 15), |acc, e| { - (max(acc.0, e.0.len() as u16), max(acc.1, e.1.len() as u16)) - }); - // Cap height between the number of entries + 3 for padding, and 20 - let height = min(max_size, self.table.items.len() + 3) as u16; - - let center = super::centered_rect(key_max + map_max + 6, height, area); - let items = self.table.items.iter().map(|(key, map)| { - Row::new([ - Line::from(*key).alignment(Alignment::Right), - Line::from("⇒"), - Line::from(*map), - ]) - }); - let header = Row::new([ - Line::from("Key").alignment(Alignment::Center), - Line::from(""), - Line::from("Action").alignment(Alignment::Center), - ]) - .style(style!(bold, underlined, fg:ctx.theme.border_focused_color)) - .height(1) - .bottom_margin(0); - - let num_items = items.len(); - super::scroll_padding( - self.table.state.selected().unwrap_or(0), - center.height as usize, - 3, - num_items, - 1, - self.table.state.offset_mut(), - ); - - let table = Table::new(items, [Constraint::Percentage(100)]) - .block( - border_block(&ctx.theme, true) - .title(title!("Help: {}", self.prev_mode.to_string())), - ) - .header(header) - .widths(Constraint::from_lengths([key_max, 1, map_max])) - .highlight_style(style!(bg:ctx.theme.hl_bg)); - - super::clear(center, buf, ctx.theme.bg); - table.render(center, buf, &mut self.table.state); - - // Only show scrollbar if content overflows - if self.table.items.len() as u16 + 2 >= center.height { - let sb = - super::scrollbar(ctx, ScrollbarOrientation::VerticalRight).begin_symbol(Some("")); - let sb_area = center.inner(Margin { - vertical: 1, - horizontal: 0, - }); - sb.render(sb_area, buf, &mut self.table.scrollbar_state); - } - } - - fn handle_event(&mut self, ctx: &mut Context, e: &Event) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - .. - }) = e - { - match code { - KeyCode::Esc | KeyCode::Char('?') | KeyCode::F(1) | KeyCode::Char('q') => { - self.prev_mode.clone_into(&mut ctx.mode); - } - KeyCode::Char('j') | KeyCode::Down => { - self.table.next_wrap(1); - } - KeyCode::Char('k') | KeyCode::Up => { - self.table.next_wrap(-1); - } - KeyCode::Char('G') => { - self.table.select(self.table.items.len() - 1); - } - KeyCode::Char('g') => { - self.table.select(0); - } - _ => {} - } - } - } - - fn get_help() -> Option> { - None - } -} diff --git a/src/widget/input.rs b/src/widget/input.rs deleted file mode 100644 index 61f888b..0000000 --- a/src/widget/input.rs +++ /dev/null @@ -1,180 +0,0 @@ -use std::cmp::{max, min}; - -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use ratatui::{ - layout::Rect, - style::Stylize, - text::Line, - widgets::{Paragraph, Widget}, - Frame, -}; - -use crate::{app::Context, util::strings}; - -pub struct InputWidget { - pub input: String, - pub char_idx: usize, - pub char_offset: usize, - pub cursor: usize, - pub max_len: usize, - pub validator: Option bool>, -} - -impl InputWidget { - pub fn new(max_len: usize, validator: Option bool>) -> Self { - InputWidget { - input: "".to_owned(), - char_idx: 0, - char_offset: 0, - cursor: 0, - max_len, - validator, - } - } - - pub fn show_cursor(&self, f: &mut Frame, area: Rect) { - let cursor = self.get_cursor_pos(); - - f.set_cursor_position((min(area.x + cursor as u16, area.x + area.width), area.y)); - } - - pub fn set_cursor(&mut self, idx: usize) { - self.char_idx = idx.min(self.max_len); - self.cursor = strings::pos_of_nth_char(&self.input, self.char_idx); - } - - pub fn clear(&mut self) { - self.input.clear(); - self.cursor = 0; - self.char_idx = 0; - } - - fn get_cursor_pos(&self) -> usize { - self.cursor - self.char_offset - } -} - -impl super::Widget for InputWidget { - fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - let fwidth = area.width as usize; - // Try to insert ellipsis if input is too long (visual only) - let (ellipsis, visible, ellipsis_back) = strings::truncate_ellipsis( - self.input.clone(), - fwidth, - ctx.config - .cursor_padding - .min((fwidth / 2).saturating_sub(1)), - self.cursor, - &mut self.char_offset, - ); - Paragraph::new(Line::from(vec![ - ellipsis.unwrap_or_default().fg(ctx.theme.border_color), - visible.into(), - ellipsis_back.unwrap_or_default().fg(ctx.theme.border_color), - ])) - .render(area, f.buffer_mut()); - } - - fn handle_event(&mut self, _ctx: &mut Context, evt: &Event) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - modifiers, - .. - }) = evt - { - use KeyCode::*; - match (code, modifiers) { - (Char(c), &KeyModifiers::NONE | &KeyModifiers::SHIFT) => { - if let Some(validator) = &self.validator { - if !validator(c) { - return; // If character is invalid, ignore it - } - } - if self.input.chars().count() < self.max_len { - self.input = strings::insert_char(&self.input, self.char_idx, *c); - self.char_idx += 1; - } - } - (Char('b') | Left, &KeyModifiers::CONTROL) => { - self.char_idx = strings::back_word(&self.input, self.char_idx); - } - (Char('w') | Right, &KeyModifiers::CONTROL) => { - self.char_idx = strings::forward_word(&self.input, self.char_idx); - } - (Delete, &KeyModifiers::CONTROL | &KeyModifiers::ALT) => { - let new_cursor = strings::forward_word(&self.input, self.char_idx); - self.input = strings::without_range(&self.input, self.char_idx..new_cursor) - } - (Backspace, &KeyModifiers::CONTROL | &KeyModifiers::ALT) => { - let new_cursor = strings::back_word(&self.input, self.char_idx); - self.input = strings::without_range(&self.input, new_cursor..self.char_idx); - self.char_idx = new_cursor; - } - (Backspace, &KeyModifiers::NONE) => { - if !self.input.is_empty() && self.char_idx > 0 { - self.char_idx -= 1; - self.input = strings::without_nth_char(&self.input, self.char_idx); - } - } - (Delete, &KeyModifiers::NONE) => { - if !self.input.is_empty() && self.char_idx < self.input.chars().count() { - self.input = strings::without_nth_char(&self.input, self.char_idx); - } - } - (Left, &KeyModifiers::NONE) - | (Char('h'), &KeyModifiers::CONTROL | &KeyModifiers::ALT) => { - self.char_idx = max(self.char_idx, 1) - 1; - } - (Right, &KeyModifiers::NONE) - | (Char('l'), &KeyModifiers::CONTROL | &KeyModifiers::ALT) => { - self.char_idx = min(self.char_idx + 1, self.input.chars().count()); - } - (End, &KeyModifiers::NONE) | (Char('e'), &KeyModifiers::CONTROL) => { - self.char_idx = self.input.chars().count(); - } - (Home, &KeyModifiers::NONE) | (Char('a'), &KeyModifiers::CONTROL) => { - self.char_idx = 0; - } - (Char('u'), &KeyModifiers::CONTROL) => { - self.char_idx = 0; - "".clone_into(&mut self.input); - } - _ => {} - }; - self.cursor = strings::pos_of_nth_char(&self.input, self.char_idx); - } - if let Event::Paste(p) = evt.to_owned() { - let space_left = self.max_len - self.input.chars().count(); - let p = match self.validator { - // Remove invalid chars - Some(v) => p.chars().filter(v).collect(), - None => p, - }; - let p = strings::replace_non_space_whitespace(&p); - let p: String = p.chars().take(space_left).collect(); - let before: String = self.input.chars().take(self.char_idx).collect(); - let after: String = self.input.chars().skip(self.char_idx).collect(); - self.input = format!("{before}{p}{after}"); - self.char_idx = min(self.char_idx + p.chars().count(), self.max_len); - - self.cursor = strings::pos_of_nth_char(&self.input, self.char_idx); - } - } - - fn get_help() -> Option> { - Some(vec![ - ("←, Ctrl-h", "Move left"), - ("→, Ctrl-l", "Move right"), - ("Ctrl-u", "Clear search"), - ("End, Ctrl-e", "End of line"), - ("Home, Ctrl-a", "Beginning of line"), - ("Ctrl-b, Ctrl-←", "Back word"), - ("Ctrl-w, Ctrl-→", "Forward word"), - ("Ctrl/Alt-Del", "Delete word forward"), - ("Ctrl/Alt-Backspace", "Delete word backwards"), - ("Del", "Delete letter forwards"), - ("Backspace", "Delete letter backwards"), - ]) - } -} diff --git a/src/widget/notifications.rs b/src/widget/notifications.rs deleted file mode 100644 index 084c000..0000000 --- a/src/widget/notifications.rs +++ /dev/null @@ -1,165 +0,0 @@ -use std::fmt::Display; - -use crossterm::event::Event; -use ratatui::{layout::Rect, Frame}; -use serde::{Deserialize, Serialize}; - -use crate::app::Context; - -use super::{notify_box::NotifyBox, Corner, Widget}; - -static MAX_NOTIFS: usize = 100; - -#[derive(Clone, Copy, PartialEq)] -pub enum NotificationType { - Info, - Warning, - Error, - Success, -} - -#[derive(Clone)] -pub struct Notification { - pub content: String, - pub notif_type: NotificationType, -} - -impl Notification { - pub fn info(content: S) -> Self { - Self { - content: content.to_string(), - notif_type: NotificationType::Info, - } - } - - pub fn warning(content: S) -> Self { - Self { - content: content.to_string(), - notif_type: NotificationType::Warning, - } - } - - pub fn error(content: S) -> Self { - Self { - content: content.to_string(), - notif_type: NotificationType::Error, - } - } - - pub fn success(content: S) -> Self { - Self { - content: content.to_string(), - notif_type: NotificationType::Success, - } - } -} - -#[derive(Clone, Copy, Serialize, Deserialize)] -pub struct NotificationConfig { - pub position: Option, - pub duration: Option, - pub max_width: Option, - pub animation_speed: Option, -} - -pub struct NotificationWidget { - notifs: Vec, - duration: f64, - position: Corner, - max_width: u16, - animation_speed: f64, -} - -impl Default for NotificationWidget { - fn default() -> Self { - Self { - notifs: vec![], - duration: 3.0, - position: Corner::TopRight, - max_width: 75, - animation_speed: 4., - } - } -} - -impl NotificationWidget { - pub fn load_config(&mut self, conf: &NotificationConfig) { - self.position = conf.position.unwrap_or(self.position); - self.duration = conf.duration.unwrap_or(self.duration).max(0.01); - self.max_width = conf.max_width.unwrap_or(self.max_width); - self.animation_speed = conf.animation_speed.unwrap_or(self.animation_speed); - } - - pub fn is_animating(&self) -> bool { - !self.notifs.is_empty() - } - - pub fn add(&mut self, notif: Notification) { - let persist = matches!(notif.notif_type, NotificationType::Error); - let notif = NotifyBox::new( - notif, - self.duration, - self.position, - self.animation_speed, - self.max_width, - persist, - ); - - self.notifs - .iter_mut() - .for_each(|n| n.add_offset(notif.height() as i32)); - - self.dismiss_oldest(); - - self.notifs.push(notif); - } - - pub fn dismiss_all(&mut self) { - self.notifs.iter_mut().for_each(|n| n.time = 1.0); - } - - fn dismiss_oldest(&mut self) { - if self.notifs.len() >= MAX_NOTIFS { - self.notifs - .drain(..=self.notifs.len().saturating_sub(MAX_NOTIFS)); - } - } - - pub fn update(&mut self, deltatime: f64, area: Rect) -> bool { - let res = self - .notifs - .iter_mut() - .fold(false, |acc, x| x.update(deltatime, area) || acc); - let finished = self - .notifs - .iter() - .filter_map(|n| match n.is_done() { - true => Some((n.offset(), n.height())), - false => None, - }) - .collect::>(); - // Offset unfinished notifications by gap left from finished notifs - for (offset, height) in finished.iter() { - self.notifs.iter_mut().for_each(|n| { - if n.get_type() == NotificationType::Error && n.offset() > *offset { - n.add_offset(-(*height as i32)); - } - }) - } - // Delete finished notifications - self.notifs.retain(|n| !n.is_done()); - res - } -} - -impl Widget for NotificationWidget { - fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - self.notifs.iter_mut().for_each(|n| n.draw(f, ctx, area)); - } - - fn handle_event(&mut self, _ctx: &mut Context, _e: &Event) {} - - fn get_help() -> Option> { - None - } -} diff --git a/src/widget/notify_box.rs b/src/widget/notify_box.rs deleted file mode 100644 index 3e51502..0000000 --- a/src/widget/notify_box.rs +++ /dev/null @@ -1,328 +0,0 @@ -use ratatui::{ - layout::{Offset, Rect}, - style::Stylize as _, - widgets::{Block, Borders, Paragraph, Widget as _}, - Frame, -}; - -use crate::{app::Context, style}; - -use super::{ - notifications::{Notification, NotificationType}, - Corner, -}; - -impl Corner { - fn is_top(&self) -> bool { - matches!(self, Self::TopLeft | Self::TopRight) - } - - fn is_left(&self) -> bool { - matches!(self, Self::TopLeft | Self::BottomLeft) - } - - fn get_start_stop( - self, - area: Rect, - width: u16, - height: u16, - start_offset: u16, - stop_offset: u16, - ) -> ((i32, i32), (i32, i32), (i32, i32)) { - let stop_x = match self.is_left() { - true => area.left() as i32 - width as i32, - false => area.right() as i32 + 1, - }; - let start_x = match self.is_left() { - true => area.left() as i32 + 1, - false => area.right() as i32 - width as i32 - 1, - }; - let start_y = match self.is_top() { - true => area.top() as i32 - height as i32 + start_offset as i32 + 2, - false => area.bottom() as i32 - start_offset as i32 - 1, - }; - let stop_y = match self.is_top() { - true => area.top() as i32 + stop_offset as i32 + 2, - false => area.bottom() as i32 - stop_offset as i32 - height as i32 - 1, - }; - ((start_x, start_y), (start_x, stop_y), (stop_x, stop_y)) - } -} - -#[derive(Copy, Clone)] -pub struct AnimateState { - time: f64, - done: bool, -} - -impl AnimateState { - fn new() -> Self { - Self { - time: 0.0, - done: false, - } - } - - pub fn translate( - &mut self, - func: fn(f64) -> f64, - start_pos: (i32, i32), - stop_pos: (i32, i32), - rate: f64, - deltatime: f64, - ) -> (i32, i32) { - if self.time >= 1.0 { - self.done = true; - } - let pos = ( - ((func(self.time) * (stop_pos.0 - start_pos.0) as f64) + start_pos.0 as f64).round() - as i32, - ((func(self.time) * (stop_pos.1 - start_pos.1) as f64) + start_pos.1 as f64).round() - as i32, - ); - self.time = 1.0_f64.min(self.time + rate * deltatime); - pos - } - - pub fn ease_out( - &mut self, - start_pos: (i32, i32), - stop_pos: (i32, i32), - rate: f64, - deltatime: f64, - ) -> (i32, i32) { - self.translate(Self::_ease_out, start_pos, stop_pos, rate, deltatime) - } - - pub fn ease_in( - &mut self, - start_pos: (i32, i32), - stop_pos: (i32, i32), - rate: f64, - deltatime: f64, - ) -> (i32, i32) { - self.translate(Self::_ease_in, start_pos, stop_pos, rate, deltatime) - } - - fn _ease_out(x: f64) -> f64 { - 1.0 - (1.0 - x).powi(3) - } - - fn _ease_in(x: f64) -> f64 { - x.powi(3) - } - - fn is_done(self) -> bool { - self.done - } - - fn reset(&mut self) { - self.time = 0.0; - self.done = false; - } -} - -pub struct NotifyBox { - notif: Notification, - pub time: f64, - pub duration: f64, - animation_speed: f64, - max_width: u16, - position: Corner, - width: u16, - height: u16, - start_offset: u16, - stop_offset: u16, - enter_state: AnimateState, - leave_state: AnimateState, - pub pos: Option<(i32, i32)>, - persist: bool, -} - -impl NotifyBox { - pub fn new( - notif: Notification, - duration: f64, - position: Corner, - animation_speed: f64, - max_width: u16, - persist: bool, - ) -> Self { - //let raw_content = notif.content.clone(); - let lines = textwrap::wrap(¬if.content, max_width as usize); - let actual_width = lines.iter().fold(0, |acc, x| acc.max(x.len())) as u16 + 2; - let height = lines.len() as u16 + 2; - NotifyBox { - width: actual_width, - height, - notif, - position, - animation_speed, - max_width, - start_offset: 0, - stop_offset: 0, - time: 0.0, - duration, - enter_state: AnimateState::new(), - leave_state: AnimateState::new(), - pos: None, - persist, - } - } - - pub fn width(&self) -> u16 { - self.width - } - - pub fn height(&self) -> u16 { - self.height - } - - pub fn offset(&self) -> u16 { - self.stop_offset - } - - pub fn is_done(&self) -> bool { - self.leave_state.is_done() - } - - pub fn is_leaving(&self) -> bool { - self.time >= 1.0 - } - - pub fn get_type(&self) -> NotificationType { - self.notif.notif_type - } - - pub fn add_offset(&mut self, offset: i32) { - self.enter_state.reset(); - - self.start_offset = self.stop_offset + self.height; - self.stop_offset = (self.stop_offset as i32 + offset).max(0) as u16; - } - - pub fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - let max_width = match self.notif.notif_type { - NotificationType::Error => (area.width / 3).max(self.max_width), - _ => area.width.min(self.max_width), - } as usize; - let lines = textwrap::wrap(&self.notif.content, max_width); - self.width = lines.iter().fold(0, |acc, x| acc.max(x.len())) as u16 + 2; - self.height = lines.len() as u16 + 2; - let content = lines.join("\n"); - - let pos = self.pos.unwrap_or(self.next_pos(ctx.deltatime, area)); - let offset = Offset { - x: self.width as i32, - y: self.height as i32, - }; - let offset_back = Offset { - x: -(self.width as i32), - y: -(self.height as i32), - }; - let rect = Rect::new( - (pos.0 + self.width as i32).max(0) as u16, - (pos.1 + self.height as i32).max(0) as u16, - self.width, - self.height, - ) - .intersection(area.offset(offset)) - .offset(offset_back); - let mut border = Borders::NONE; - if pos.0 >= 0 { - border |= Borders::LEFT - } - if pos.0 + self.width as i32 <= area.right() as i32 { - border |= Borders::RIGHT - } - if pos.1 >= 0 { - border |= Borders::TOP - } - if pos.1 + self.height as i32 <= area.bottom() as i32 { - border |= Borders::BOTTOM - } - let scroll_x = (pos.0 + 1).min(0).unsigned_abs() as u16; - let scroll_y = (pos.1 + 1).min(0).unsigned_abs() as u16; - let (title, border_color, fg_color) = match self.notif.notif_type { - NotificationType::Info => (None, ctx.theme.info, ctx.theme.fg), - NotificationType::Warning => (Some("Warning"), ctx.theme.warning, ctx.theme.warning), - NotificationType::Error => ( - Some("Error: Press ESC to dismiss..."), - ctx.theme.error, - ctx.theme.error, - ), - NotificationType::Success => (Some("Success"), ctx.theme.success, ctx.theme.fg), - }; - let block = { - let mut block = Block::new() - .border_style(style!(fg:border_color)) - .bg(ctx.theme.bg) - .fg(fg_color) - .borders(border) - .border_type(ctx.theme.border); - if border.contains(Borders::TOP) { - if let Some(sub) = title.and_then(|t| t.get((scroll_x as usize)..)) { - block = block.title(sub) - } - } - block - }; - //let block = match self.notif_type { - // Notification::Error => { - // let mut block = Block::new() - // .border_style(style!(fg:ctx.theme.error)) - // .bg(ctx.theme.bg) - // .fg(ctx.theme.error) - // .borders(border) - // .border_type(ctx.theme.border); - // if border.contains(Borders::TOP) { - // let title = "Error: Press ESC to dismiss..."; - // if let Some(sub) = title.get((scroll_x as usize)..) { - // block = block.title(sub); - // } - // } - // block - // } - // _ => Block::new() - // .border_style(style!(fg:ctx.theme.border_focused_color)) - // .bg(ctx.theme.bg) - // .fg(ctx.theme.fg) - // .borders(border) - // .border_type(ctx.theme.border), - //}; - - super::clear(rect, f.buffer_mut(), ctx.theme.bg); - Paragraph::new(content) - .block(block) - .scroll((scroll_y, scroll_x)) - .render(rect, f.buffer_mut()); - } - - fn next_pos(&mut self, deltatime: f64, area: Rect) -> (i32, i32) { - let (start_pos, stop_pos, leave_pos) = self.position.get_start_stop( - area, - self.width, - self.height, - self.start_offset, - self.stop_offset, - ); - if self.time < 1.0 { - self.enter_state - .ease_out(start_pos, stop_pos, self.animation_speed, deltatime) - } else { - self.leave_state - .ease_in(stop_pos, leave_pos, self.animation_speed / 2.0, deltatime) - } - } - - pub fn update(&mut self, deltatime: f64, area: Rect) -> bool { - let last_pos = self.pos; - self.pos = Some(self.next_pos(deltatime, area)); - - // Dont automatically dismiss errors - if self.enter_state.is_done() && !self.persist { - self.time = 1.0_f64.min(self.time + deltatime / self.duration); - } - last_pos != self.pos - } -} diff --git a/src/widget/page.rs b/src/widget/page.rs deleted file mode 100644 index 924e82a..0000000 --- a/src/widget/page.rs +++ /dev/null @@ -1,98 +0,0 @@ -use std::cmp::{max, min}; - -use crate::{ - app::{Context, LoadType, Mode}, - title, -}; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}; -use ratatui::{ - layout::{Margin, Rect}, - widgets::{Paragraph, Widget as _}, - Frame, -}; - -use super::{ - border_block, - input::{self, InputWidget}, - Widget, -}; - -pub struct PagePopup { - pub input: InputWidget, -} - -impl Default for PagePopup { - fn default() -> Self { - PagePopup { - input: InputWidget::new(3, Some(char::is_ascii_digit)), - } - } -} - -impl Widget for PagePopup { - fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - let buf = f.buffer_mut(); - let center = super::centered_rect(13, 3, area); - let page_p = Paragraph::new(self.input.input.clone()); - let indicator = - Paragraph::new(">").block(border_block(&ctx.theme, true).title(title!("Goto Page"))); - super::clear(center, buf, ctx.theme.bg); - indicator.render(center, buf); - - let input_area = center.inner(Margin { - vertical: 1, - horizontal: 1, - }); - let input_area = Rect::new( - input_area.x + 2, - input_area.y, - input_area.width, - input_area.height, - ); - page_p.render(input_area, buf); - - if ctx.mode == Mode::Page { - self.input.show_cursor(f, input_area); - } - } - - fn handle_event(&mut self, ctx: &mut Context, e: &Event) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - .. - }) = e - { - match code { - KeyCode::Esc => { - ctx.mode = Mode::Normal; - // Clear input on Esc - self.input.clear(); - } - KeyCode::Enter => { - ctx.page = max( - min( - self.input.input.parse().unwrap_or(1), - ctx.results.response.last_page, - ), - 1, - ); - ctx.mode = Mode::Loading(LoadType::Searching); - - // Clear input on Enter - self.input.clear(); - } - _ => {} - } - } - self.input.handle_event(ctx, e); - } - - fn get_help() -> Option> { - let mut search_help = vec![("Enter", "Confirm"), ("Esc", "Stop")]; - if let Some(input_help) = input::InputWidget::get_help() { - search_help.extend(input_help); - } - Some(search_help) - } -} diff --git a/src/widget/results.rs b/src/widget/results.rs deleted file mode 100644 index b8df0d4..0000000 --- a/src/widget/results.rs +++ /dev/null @@ -1,452 +0,0 @@ -use core::str; - -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use ratatui::{ - layout::{Margin, Rect}, - style::{Style, Stylize as _}, - symbols, - text::Line, - widgets::{Clear, Paragraph, Row, ScrollbarOrientation, StatefulWidget, Table, Widget}, - Frame, -}; - -use crate::{ - app::{Context, LoadType, Mode}, - title, - widget::sort::SortDir, -}; - -use super::{border_block, centered_rect, VirtualStatefulTable}; - -#[derive(Clone, Copy, PartialEq, Eq)] -enum VisualMode { - Toggle, - Add, - Remove, - None, -} - -pub struct ResultsWidget { - pub table: VirtualStatefulTable, - visual_mode: VisualMode, - visual_anchor: usize, -} - -impl ResultsWidget { - pub fn reset(&mut self) { - self.table.select(0); - *self.table.state.offset_mut() = 0; - } - - fn try_select_add(&self, ctx: &mut Context, start: usize, stop: usize) { - if let Some(item) = ctx.results.response.items.get(start..=stop) { - item.iter().for_each(|i| { - if !ctx.batch.iter().any(|s| s.id == i.id) { - ctx.batch.push(i.to_owned()); - } - }); - } - } - - fn try_select_remove(&self, ctx: &mut Context, start: usize, stop: usize) { - if let Some(item) = ctx.results.response.items.get(start..=stop) { - item.iter().for_each(|i| { - if let Some(p) = ctx.batch.iter().position(|s| s.id == i.id) { - ctx.batch.remove(p); - } - }) - } - } - - fn try_select_toggle(&self, ctx: &mut Context, start: usize, stop: usize) { - if let Some(item) = ctx.results.response.items.get(start..=stop) { - item.iter().for_each(|i| { - if let Some(p) = ctx.batch.iter().position(|s| s.id == i.id) { - ctx.batch.remove(p); - } else { - ctx.batch.push(i.to_owned()); - } - }) - } - } - - fn select_on_move( - &self, - ctx: &mut Context, - prev: usize, - range_start: usize, - range_stop: usize, - ) { - if prev == range_stop { - return; - } - match self.visual_mode { - VisualMode::None => {} - VisualMode::Toggle => { - if range_stop.abs_diff(self.visual_anchor) < prev.abs_diff(self.visual_anchor) { - let dir = (prev as isize - range_stop as isize).signum(); - self.try_select_toggle( - ctx, - range_start.saturating_add_signed(dir), - range_stop.saturating_add_signed(dir), - ) - } else { - self.try_select_toggle(ctx, range_start, range_stop) - } - } - VisualMode::Add => self.try_select_add(ctx, range_start, range_stop), - VisualMode::Remove => self.try_select_remove(ctx, range_start, range_stop), - } - } -} - -impl Default for ResultsWidget { - fn default() -> Self { - ResultsWidget { - table: VirtualStatefulTable::new(), - visual_mode: VisualMode::None, - visual_anchor: 0, - } - } -} - -impl super::Widget for ResultsWidget { - fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - let buf = f.buffer_mut(); - let focus_color = match ctx.mode { - Mode::Normal | Mode::KeyCombo(_) => ctx.theme.border_focused_color, - _ => ctx.theme.border_color, - }; - let header: Row = ctx.results.table.headers.clone().into(); - let header = header.fg(focus_color).underlined(); - - Clear.render(area, buf); - let items: Vec = match &ctx.load_type { - Some(loadtype) => { - let message = format!("{}…", loadtype); - let load_area = centered_rect(message.len() as u16, 1, area); - Paragraph::new(message).render(load_area, buf); - vec![] - } - _ => ctx - .results - .table - .rows - .clone() - .into_iter() - .map(Into::into) - .collect(), - }; - - let sb = super::scrollbar(ctx, ScrollbarOrientation::VerticalRight).begin_symbol(Some("")); - let sb_area = area.inner(Margin { - vertical: 1, - horizontal: 0, - }); - - let num_items = items.len(); - let first_item = (ctx.page - 1) * 75; - let focused = matches!(ctx.mode, Mode::Normal | Mode::KeyCombo(_)); - - let dl_src = title!( - "dl: {}, src: {}", - ctx.client.to_string(), - ctx.src.to_string() - ); - - let title = title!( - "Results {}-{} ({} total): Page {}/{}", - first_item + 1, - num_items + first_item, - ctx.results.response.total_results, - ctx.page, - ctx.results.response.last_page, - ); - let mut block = border_block(&ctx.theme, focused) - .title(title) - .title_top(Line::from(dl_src).right_aligned()); - if !ctx.last_key.is_empty() { - let key_str = title!(ctx.last_key); - block = block.title_bottom(Line::from(key_str).right_aligned()); - } - - let table = Table::new(items, ctx.results.table.binding.to_owned()) - .header(header) - .block(block) - .highlight_style(Style::default().bg(ctx.theme.hl_bg)); - - let visible_height = area.height.saturating_sub(3) as usize; - super::scroll_padding( - self.table.selected().unwrap_or(0), - area.height as usize, - 3, - num_items, - ctx.config.scroll_padding.min(visible_height / 2), - self.table.state.offset_mut(), - ); - - StatefulWidget::render(table, area, buf, &mut self.table.state); - StatefulWidget::render( - sb, - sb_area, - buf, - &mut self.table.scrollbar_state.content_length(num_items), - ); - - if ctx.load_type.is_none() && num_items == 0 { - let center = centered_rect(10, 1, area); - Paragraph::new("No results").render(center, buf); - } - - if area.height >= 3 { - let offset = self.table.state.offset(); - if let Some(visible_items) = ctx - .results - .response - .items - .get(offset..(offset + visible_height)) - { - let selected_ids: Vec = - ctx.batch.clone().into_iter().map(|i| i.id).collect(); - let vert_left = ctx.theme.border.to_border_set().vertical_left; - let lines = visible_items - .iter() - .map(|i| { - Line::from(match selected_ids.contains(&i.id) { - true => symbols::border::QUADRANT_BLOCK, - false => vert_left, - }) - }) - .collect::>(); - let para = Paragraph::new(lines); - let para_area = Rect::new(area.x, area.y + 2, 1, area.height - 3); - para.render(para_area, buf); - } - } - } - - fn handle_event(&mut self, ctx: &mut Context, e: &Event) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - modifiers, - .. - }) = e - { - use KeyCode::*; - match (code, modifiers) { - (Char('c'), &KeyModifiers::NONE) => { - ctx.mode = Mode::Category; - } - (Char('s'), &KeyModifiers::NONE) => { - ctx.mode = Mode::Sort(SortDir::Desc); - } - (Char('S'), &KeyModifiers::SHIFT) => { - ctx.mode = Mode::Sort(SortDir::Asc); - } - (Char('f'), &KeyModifiers::NONE) => { - ctx.mode = Mode::Filter; - } - (Char('t'), &KeyModifiers::NONE) => { - ctx.mode = Mode::Theme; - } - (Char('/') | Char('i'), &KeyModifiers::NONE) => { - ctx.mode = Mode::Search; - } - (Char('p'), &KeyModifiers::CONTROL) => { - ctx.mode = Mode::Page; - } - (Char('p') | Char('h') | Left, &KeyModifiers::NONE) => { - if ctx.page > 1 { - ctx.page -= 1; - ctx.mode = Mode::Loading(LoadType::Searching); - } - } - (Char('n') | Char('l') | Right, &KeyModifiers::NONE) => { - if ctx.page < ctx.results.response.last_page { - ctx.page += 1; - ctx.mode = Mode::Loading(LoadType::Searching); - } - } - (Char('r'), &KeyModifiers::NONE) => { - ctx.mode = Mode::Loading(LoadType::Searching); - } - (Char('q'), &KeyModifiers::NONE) => { - ctx.quit(); - } - (Char('j') | KeyCode::Down, &KeyModifiers::NONE) => { - let prev = self.table.selected().unwrap_or(0); - let selected = self.table.next(ctx.results.response.items.len(), 1); - self.select_on_move(ctx, prev, selected, selected); - } - (Char('k') | KeyCode::Up, &KeyModifiers::NONE) => { - let prev = self.table.selected().unwrap_or(0); - let selected = self.table.next(ctx.results.response.items.len(), -1); - self.select_on_move(ctx, prev, selected, selected); - } - (Char('J'), &KeyModifiers::SHIFT) => { - let prev = self.table.selected().unwrap_or(0); - let selected = self.table.next(ctx.results.response.items.len(), 4); - self.select_on_move(ctx, prev, prev + 1, selected); - } - (Char('K'), &KeyModifiers::SHIFT) => { - let prev = self.table.selected().unwrap_or(0); - let selected = self.table.next(ctx.results.response.items.len(), -4); - self.select_on_move(ctx, prev, selected, prev.saturating_sub(1)); - } - (Char('G'), &KeyModifiers::SHIFT) => { - let prev = self.table.selected().unwrap_or(0); - let selected = ctx.results.response.items.len().saturating_sub(1); - self.table.select(selected); - - if self.visual_mode != VisualMode::None && prev != selected { - self.select_on_move(ctx, prev, prev + 1, selected); - } - } - (Char('g'), &KeyModifiers::NONE) => { - let prev = self.table.selected().unwrap_or(0); - self.table.select(0); - self.select_on_move(ctx, prev, 0, prev.saturating_sub(1)); - } - (Char('H') | Char('P'), &KeyModifiers::SHIFT) => { - if ctx.page != 1 { - ctx.page = 1; - ctx.mode = Mode::Loading(LoadType::Searching); - } - } - (Char('L') | Char('N'), &KeyModifiers::SHIFT) => { - if ctx.page != ctx.results.response.last_page - && ctx.results.response.last_page > 0 - { - ctx.page = ctx.results.response.last_page; - ctx.mode = Mode::Loading(LoadType::Searching); - } - } - (Enter, &KeyModifiers::NONE) => { - ctx.mode = Mode::Loading(LoadType::Downloading); - } - (Char('s'), &KeyModifiers::CONTROL) => { - ctx.mode = Mode::Sources; - } - (Char('d'), &KeyModifiers::NONE) => { - ctx.mode = Mode::Clients; - } - (Char('u'), &KeyModifiers::NONE) => { - ctx.mode = Mode::User; - } - (Char('o'), &KeyModifiers::NONE) => { - let link = ctx - .results - .response - .items - .get(self.table.state.selected().unwrap_or(0)) - .map(|item| item.post_link.clone()) - .unwrap_or("https://nyaa.si".to_owned()); - let res = open::that_detached(&link); - if let Err(e) = res { - ctx.notify_error(format!("Failed to open {}:\n{}", link, e)); - } else { - ctx.notify_info(format!("Opened {}", link)); - } - } - (Char('y'), &KeyModifiers::NONE) => ctx.mode = Mode::KeyCombo("y".to_string()), - (Char(' '), &KeyModifiers::CONTROL) => { - if self.visual_mode != VisualMode::Toggle { - ctx.notify_info("Entered VISUAL TOGGLE mode"); - self.visual_anchor = self.table.selected().unwrap_or(0); - self.try_select_toggle(ctx, self.visual_anchor, self.visual_anchor); - self.visual_mode = VisualMode::Toggle; - } else { - ctx.notify_info("Exited VISUAL TOGGLE mode"); - self.visual_anchor = 0; - self.visual_mode = VisualMode::None; - } - } - (Char('v'), &KeyModifiers::NONE) => { - if self.visual_mode != VisualMode::Add { - ctx.notify_info("Entered VISUAL ADD mode"); - self.visual_anchor = self.table.selected().unwrap_or(0); - self.try_select_add(ctx, self.visual_anchor, self.visual_anchor); - self.visual_mode = VisualMode::Add; - } else { - ctx.notify_info("Exited VISUAL ADD mode"); - self.visual_anchor = 0; - self.visual_mode = VisualMode::None; - } - } - (Char('V'), &KeyModifiers::SHIFT) => { - if self.visual_mode != VisualMode::Remove { - ctx.notify_info("Entered VISUAL REMOVE mode"); - self.visual_anchor = self.table.selected().unwrap_or(0); - self.try_select_remove(ctx, self.visual_anchor, self.visual_anchor); - self.visual_mode = VisualMode::Remove; - } else { - ctx.notify_info("Exited VISUAL REMOVE mode"); - self.visual_anchor = 0; - self.visual_mode = VisualMode::None; - } - } - (Char(' '), &KeyModifiers::NONE) => { - if let Some(sel) = self.table.state.selected() { - if let Some(item) = &mut ctx.results.response.items.get_mut(sel) { - if let Some(p) = ctx.batch.iter().position(|s| s.id == item.id) { - ctx.batch.remove(p); - } else { - ctx.batch.push(item.to_owned()); - } - } - } - } - (Tab | BackTab, _) => { - ctx.mode = Mode::Batch; - } - (Esc, &KeyModifiers::NONE) => { - match self.visual_mode { - VisualMode::Add => ctx.notify_info("Exited VISUAL ADD mode"), - VisualMode::Remove => ctx.notify_info("Exited VISUAL REMOVE mode"), - VisualMode::Toggle => ctx.notify_info("Exited VISUAL TOGGLE mode"), - VisualMode::None => ctx.dismiss_notifications(), - } - self.visual_anchor = 0; - self.visual_mode = VisualMode::None; - } - _ => {} - } - } - } - - fn get_help() -> Option> { - Some(vec![ - ("Enter", "Confirm"), - ("Esc", "Dismiss notification"), - ("q", "Exit App"), - ("g/G", "Goto Top/Bottom"), - ("k, ↑", "Up"), - ("j, ↓", "Down"), - ("K, J", "Up/Down 4 items"), - ("n, l, →", "Next Page"), - ("p, h, ←", "Prev Page"), - ("N, L", "Last Page"), - ("P, H", "First Page"), - ("r", "Reload"), - ("o", "Open in browser"), - ( - "yt, ym, yp, yi, yn", - "Copy torrent/magnet/post link/imdb id/name", - ), - ("Space", "Toggle item for batch download"), - ("v/V/Ctrl-Space", "Enter visual add/remove/toggle mode"), - ("Tab/Shift-Tab", "Switch to Batches"), - ("/, i", "Search"), - ("c", "Categories"), - ("f", "Filters"), - ("s", "Sort"), - ("S", "Sort reversed"), - ("t", "Themes"), - ("u", "Filter by User"), - ("d", "Select download client"), - ("Ctrl-p", "Goto page"), - ("Ctrl-s", "Select source"), - ]) - } -} diff --git a/src/widget/search.rs b/src/widget/search.rs deleted file mode 100644 index 144c5a2..0000000 --- a/src/widget/search.rs +++ /dev/null @@ -1,88 +0,0 @@ -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use ratatui::{ - layout::{Margin, Rect}, - style::Stylize, - text::Line, - widgets::{Clear, Widget}, - Frame, -}; - -use crate::{ - app::{Context, LoadType, Mode}, - title, -}; - -use super::{ - border_block, - input::{self, InputWidget}, -}; - -pub struct SearchWidget { - pub input: InputWidget, -} - -impl Default for SearchWidget { - fn default() -> Self { - SearchWidget { - input: InputWidget::new(300, Some(|_| true)), - } - } -} - -impl super::Widget for SearchWidget { - fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - let buf = f.buffer_mut(); - let help_title = title!( - "Press ".into(); - "F1".bold(); - " or ".into(); - "?".bold(); - " for help".into(); - ); - let block = border_block(&ctx.theme, ctx.mode == Mode::Search) - .title(title!("Search")) - .title_top(Line::from(help_title).right_aligned()); - Clear.render(area, buf); - block.render(area, buf); - let input_area = area.inner(Margin { - vertical: 1, - horizontal: 1, - }); - - self.input.draw(f, ctx, input_area); - if ctx.mode == Mode::Search { - self.input.show_cursor(f, input_area); - } - } - - fn handle_event(&mut self, ctx: &mut Context, evt: &Event) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - modifiers, - .. - }) = evt - { - use KeyCode::*; - match (code, modifiers) { - (Esc, &KeyModifiers::NONE) => { - ctx.mode = Mode::Normal; - } - (Enter, &KeyModifiers::NONE) => { - ctx.mode = Mode::Loading(LoadType::Searching); - ctx.page = 1; // Go back to first page - } - _ => {} - }; - } - self.input.handle_event(ctx, evt); - } - - fn get_help() -> Option> { - let mut search_help = vec![("Enter", "Confirm"), ("Esc", "Stop")]; - if let Some(input_help) = input::InputWidget::get_help() { - search_help.extend(input_help); - } - Some(search_help) - } -} diff --git a/src/widget/sort.rs b/src/widget/sort.rs deleted file mode 100644 index d11e91a..0000000 --- a/src/widget/sort.rs +++ /dev/null @@ -1,151 +0,0 @@ -use std::fmt::Display; - -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}; -use ratatui::{ - layout::{Constraint, Rect}, - widgets::{Row, StatefulWidget as _, Table}, - Frame, -}; -use serde::{Deserialize, Serialize}; - -use crate::{ - app::{Context, LoadType, Mode}, - style, title, -}; - -use super::{border_block, VirtualStatefulTable, Widget}; - -#[derive(Clone, Copy)] -pub struct SelectedSort { - pub sort: usize, - pub dir: SortDir, -} - -impl Default for SelectedSort { - fn default() -> Self { - Self { - sort: 0, - dir: SortDir::Desc, - } - } -} - -#[derive(PartialEq, Clone, Copy, Serialize, Deserialize)] -pub enum SortDir { - #[serde(rename = "Desc")] - Desc, - #[serde(rename = "Asc")] - Asc, -} - -impl SortDir { - pub fn to_url(self) -> String { - match self { - SortDir::Desc => "desc", - SortDir::Asc => "asc", - } - .to_owned() - } -} - -impl Display for SortDir { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - SortDir::Asc => "Ascending", - SortDir::Desc => "Descending", - } - ) - } -} - -pub struct SortPopup { - pub table: VirtualStatefulTable, - pub selected: SelectedSort, -} - -impl Default for SortPopup { - fn default() -> Self { - SortPopup { - table: VirtualStatefulTable::new(), - selected: SelectedSort::default(), - } - } -} - -impl Widget for SortPopup { - fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - let buf = f.buffer_mut(); - let center = super::centered_rect(30, ctx.src_info.sorts.len() as u16 + 2, area); - let items = ctx.src_info.sorts.iter().enumerate().map(|(i, item)| { - Row::new([match i == self.selected.sort { - true => format!("  {}", item), - false => format!(" {}", item), - }]) - }); - let table = Table::new(items, [Constraint::Percentage(100)]) - .block(border_block(&ctx.theme, true).title(title!(match ctx.mode - == Mode::Sort(SortDir::Asc) - { - true => "Sort Ascending", - false => "Sort Descending", - }))) - .highlight_style(style!(bg:ctx.theme.hl_bg)); - super::clear(center, buf, ctx.theme.bg); - table.render(center, buf, &mut self.table.state); - } - - fn handle_event(&mut self, ctx: &mut Context, e: &Event) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - .. - }) = e - { - match code { - KeyCode::Esc | KeyCode::Char('s') | KeyCode::Char('q') => { - ctx.mode = Mode::Normal; - } - KeyCode::Char('j') | KeyCode::Down => { - self.table.next_wrap(ctx.src_info.sorts.len(), 1); - } - KeyCode::Char('k') | KeyCode::Up => { - self.table.next_wrap(ctx.src_info.sorts.len(), -1); - } - KeyCode::Char('G') => { - self.table.select(ctx.src_info.sorts.len() - 1); - } - KeyCode::Char('g') => { - self.table.select(0); - } - KeyCode::Enter => { - if let Some(i) = self.table.state.selected() { - self.selected.sort = i; - self.selected.dir = match ctx.mode == Mode::Sort(SortDir::Asc) { - true => SortDir::Asc, - false => SortDir::Desc, - }; - ctx.mode = Mode::Loading(LoadType::Sorting); - if let Some(s) = ctx.src_info.sorts.get(i) { - ctx.notify_info(format!("Sort by \"{}\" {}", s, self.selected.dir)); - } - } - } - _ => {} - } - } - } - - fn get_help() -> Option> { - Some(vec![ - ("Enter", "Confirm"), - ("Esc, s, q", "Close"), - ("j, ↓", "Down"), - ("k, ↑", "Up"), - ("g", "Top"), - ("G", "Bottom"), - ]) - } -} diff --git a/src/widget/sources.rs b/src/widget/sources.rs deleted file mode 100644 index 17ffb79..0000000 --- a/src/widget/sources.rs +++ /dev/null @@ -1,104 +0,0 @@ -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}; -use ratatui::{ - layout::{Constraint, Rect}, - widgets::{Row, StatefulWidget as _, Table}, - Frame, -}; -use strum::VariantArray; - -use crate::{ - app::{Context, LoadType, Mode}, - source::Sources, - style, title, -}; - -use super::{border_block, StatefulTable, Widget}; - -pub struct SourcesPopup { - pub table: StatefulTable, -} - -impl Default for SourcesPopup { - fn default() -> Self { - SourcesPopup { - table: StatefulTable::new(Sources::VARIANTS), - } - } -} - -impl Widget for SourcesPopup { - fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - let buf = f.buffer_mut(); - let center = super::centered_rect(30, self.table.items.len() as u16 + 2, area); - let items = self.table.items.iter().map(|item| { - Row::new(vec![match item == &ctx.src { - true => format!("  {}", item), - false => format!(" {}", item), - }]) - }); - super::clear(center, buf, ctx.theme.bg); - let table = Table::new(items, [Constraint::Percentage(100)]) - .block(border_block(&ctx.theme, true).title(title!("Source"))) - .highlight_style(style!(bg:ctx.theme.hl_bg)); - table.render(center, buf, &mut self.table.state); - } - - fn handle_event(&mut self, ctx: &mut Context, e: &Event) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - .. - }) = e - { - match code { - KeyCode::Esc | KeyCode::Char('s') | KeyCode::Char('q') => { - ctx.mode = Mode::Normal; - } - KeyCode::Char('j') | KeyCode::Down => { - self.table.next_wrap(1); - } - KeyCode::Char('k') | KeyCode::Up => { - self.table.next_wrap(-1); - } - KeyCode::Char('G') => { - self.table.select(self.table.items.len() - 1); - } - KeyCode::Char('g') => { - self.table.select(0); - } - KeyCode::Enter => { - if let Some(src) = self.table.selected() { - if !src.eq(&ctx.src) { - ctx.src = *src; - ctx.config.source = *src; - ctx.mode = Mode::Loading(LoadType::Sourcing); - src.load_config(&mut ctx.config.sources); - match ctx.save_config() { - Ok(_) => ctx.notify_info(format!("Updated source to \"{}\"", src)), - Err(e) => ctx.notify_error(format!( - "Failed to update default source in config file:\n{}", - e - )), - } - } else { - // If source is the same, do nothing - ctx.mode = Mode::Normal; - } - } - } - _ => {} - } - } - } - - fn get_help() -> Option> { - Some(vec![ - ("Enter", "Confirm"), - ("Esc, Ctrl-s, q", "Close"), - ("j, ↓", "Down"), - ("k, ↑", "Up"), - ("g", "Top"), - ("G", "Bottom"), - ]) - } -} diff --git a/src/widget/themes.rs b/src/widget/themes.rs deleted file mode 100644 index 65eef67..0000000 --- a/src/widget/themes.rs +++ /dev/null @@ -1,160 +0,0 @@ -use std::cmp::min; - -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}; -use ratatui::{ - layout::{Constraint, Margin, Rect}, - widgets::{Row, ScrollbarOrientation, StatefulWidget as _, Table}, - Frame, -}; - -use crate::{ - app::{Context, Mode}, - style, title, -}; - -use super::{border_block, VirtualStatefulTable, Widget}; - -pub struct ThemePopup { - pub table: VirtualStatefulTable, - pub selected: usize, -} - -impl Default for ThemePopup { - fn default() -> Self { - ThemePopup { - table: VirtualStatefulTable::new(), - selected: 0, - } - } -} - -fn preview_theme(idx: usize, ctx: &mut Context) { - if let Some((_, theme)) = ctx.themes.get_index(idx) { - ctx.theme = theme.clone(); - ctx.results.table = ctx.src.format_table( - &ctx.results.response.items, - &ctx.results.search, - &ctx.config.sources, - &ctx.theme, - ); - } -} - -impl Widget for ThemePopup { - fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - let buf = f.buffer_mut(); - let height = min(min(ctx.themes.len() as u16 + 2, 10), area.height); - let center = super::centered_rect(30, height, area); - let items = ctx.themes.keys().enumerate().map(|(i, item)| { - Row::new(vec![ - match i == self.selected { - true => format!("  {}", item), - false => format!(" {}", item), - }, - item.to_owned(), - ]) - }); - - let num_items = items.len(); - super::scroll_padding( - self.table.selected().unwrap_or(0), - center.height as usize, - 2, - num_items, - 1, - self.table.state.offset_mut(), - ); - - let table = Table::new(items, [Constraint::Percentage(100)]) - .block(border_block(&ctx.theme, true).title(title!("Theme"))) - .highlight_style(style!(bg:ctx.theme.hl_bg)); - super::clear(center, buf, ctx.theme.bg); - table.render(center, buf, &mut self.table.state); - - // Only show scrollbar if content overflows - if ctx.themes.len() as u16 + 1 >= center.height { - let sb = super::scrollbar(ctx, ScrollbarOrientation::VerticalRight); - let sb_area = center.inner(Margin { - vertical: 1, - horizontal: 0, - }); - sb.render( - sb_area, - buf, - &mut self.table.scrollbar_state.content_length(num_items), - ); - } - } - - fn handle_event(&mut self, ctx: &mut Context, e: &Event) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - .. - }) = e - { - match code { - KeyCode::Esc | KeyCode::Char('t') | KeyCode::Char('q') => { - ctx.mode = Mode::Normal; - if Some(self.selected) != self.table.selected() { - preview_theme(self.selected, ctx); - } - } - KeyCode::Char('j') | KeyCode::Down => { - let idx = self.table.next_wrap(ctx.themes.len(), 1); - preview_theme(idx, ctx); - } - KeyCode::Char('k') | KeyCode::Up => { - let idx = self.table.next_wrap(ctx.themes.len(), -1); - preview_theme(idx, ctx); - } - KeyCode::Char('G') => { - let idx = ctx.themes.len().saturating_sub(1); - self.table.select(idx); - preview_theme(idx, ctx); - } - KeyCode::Char('g') => { - self.table.select(0); - preview_theme(0, ctx); - } - KeyCode::Enter => { - let idx = self.table.selected().unwrap_or(0); - if let Some((_, theme)) = ctx.themes.get_index(idx) { - let theme_name = theme.name.clone(); - self.selected = idx; - ctx.theme = theme.clone(); - ctx.config.theme.clone_from(&theme.name); - ctx.results.table = ctx.src.format_table( - &ctx.results.response.items, - &ctx.results.search, - &ctx.config.sources, - &ctx.theme, - ); - match ctx.save_config() { - Ok(_) => { - ctx.notify_info(format!("Updated theme to \"{}\"", theme_name)) - } - Err(e) => ctx.notify_error(format!( - "Failed to update default theme in config file:\n{}", - e - )), - } - } - ctx.mode = Mode::Normal; - } - _ => {} - } - } - } - - fn get_help() -> Option> { - Some(vec![ - ("Enter", "Confirm"), - ("Esc, t, q", "Close"), - ("j, ↓", "Down"), - ("k, ↑", "Up"), - ("g", "Top"), - ("G", "Bottom"), - ]) - } -} diff --git a/src/widget/user.rs b/src/widget/user.rs deleted file mode 100644 index 04fa7e2..0000000 --- a/src/widget/user.rs +++ /dev/null @@ -1,86 +0,0 @@ -use crate::{ - app::{Context, LoadType, Mode}, - title, -}; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind}; -use ratatui::{ - layout::{Margin, Rect}, - widgets::{Paragraph, Widget as _}, - Frame, -}; - -use super::{ - border_block, - input::{self, InputWidget}, - Widget, -}; - -pub struct UserPopup { - pub input: InputWidget, -} - -impl Default for UserPopup { - fn default() -> Self { - UserPopup { - input: InputWidget::new(26, Some(|e| e.is_ascii())), - } - } -} - -impl Widget for UserPopup { - fn draw(&mut self, f: &mut Frame, ctx: &Context, area: Rect) { - let buf = f.buffer_mut(); - let center = super::centered_rect(30, 3, area); - let page_p = Paragraph::new(self.input.input.clone()); - let indicator = Paragraph::new(">") - .block(border_block(&ctx.theme, true).title(title!("Posts by User"))); - super::clear(center, buf, ctx.theme.bg); - indicator.render(center, buf); - - let input_area = center.inner(Margin { - vertical: 1, - horizontal: 1, - }); - let input_area = Rect::new( - input_area.x + 2, - input_area.y, - input_area.width, - input_area.height, - ); - page_p.render(input_area, buf); - - if ctx.mode == Mode::User { - self.input.show_cursor(f, input_area); - } - } - - fn handle_event(&mut self, ctx: &mut Context, e: &Event) { - if let Event::Key(KeyEvent { - code, - kind: KeyEventKind::Press, - .. - }) = e - { - match code { - KeyCode::Esc => { - ctx.mode = Mode::Normal; - } - KeyCode::Enter => { - ctx.user = Some(self.input.input.to_owned()); - ctx.mode = Mode::Loading(LoadType::Searching); - } - _ => { - self.input.handle_event(ctx, e); - } - } - } - } - - fn get_help() -> Option> { - let mut search_help = vec![("Enter", "Confirm"), ("Esc", "Stop")]; - if let Some(input_help) = input::InputWidget::get_help() { - search_help.extend(input_help); - } - Some(search_help) - } -} diff --git a/tests/common/mod.rs b/tests-old/common/mod.rs similarity index 100% rename from tests/common/mod.rs rename to tests-old/common/mod.rs diff --git a/tests-old/config/config.toml b/tests-old/config/config.toml new file mode 100644 index 0000000..e69de29 diff --git a/tests/config/themes/dracula2.toml b/tests-old/config/themes/dracula2.toml similarity index 100% rename from tests/config/themes/dracula2.toml rename to tests-old/config/themes/dracula2.toml diff --git a/tests/input.rs b/tests-old/input.rs similarity index 100% rename from tests/input.rs rename to tests-old/input.rs diff --git a/tests/popups.rs b/tests-old/popups.rs similarity index 100% rename from tests/popups.rs rename to tests-old/popups.rs diff --git a/tests/query.rs b/tests-old/query.rs similarity index 100% rename from tests/query.rs rename to tests-old/query.rs