diff --git a/Cargo.lock b/Cargo.lock index 75e84d9..14a514d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,9 +170,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" dependencies = [ "serde", ] @@ -206,9 +206,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.99" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c51067fd44124faa7f870b4b1c969379ad32b2ba805aa959430ceaa384f695" +checksum = "2755ff20a1d93490d26ba33a6f092a38a508398a5320df5d4b3014fcccce9410" [[package]] name = "cfg-if" @@ -227,9 +227,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.7" +version = "4.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db83dced34638ad474f39f250d7fea9598bdd239eaced1bdf45d597da0f433f" +checksum = "84b3edb18336f4df585bc9aa31dd99c036dfa5dc5e9a2939a722a188f3a8970d" dependencies = [ "clap_builder", "clap_derive", @@ -237,9 +237,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.7" +version = "4.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7e204572485eb3fbf28f871612191521df159bc3e15a9f5064c66dba3a8c05f" +checksum = "c1c09dd5ada6c6c78075d6fd0da3f90d8080651e2d6cc8eb2f1aaa4034ced708" dependencies = [ "anstream", "anstyle", @@ -249,9 +249,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.5" +version = "4.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c780290ccf4fb26629baa7a1081e68ced113f1d3ec302fa5948f1c381ebf06c6" +checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085" dependencies = [ "heck", "proc-macro2", @@ -307,12 +307,13 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "crossterm_winapi", "futures-core", "libc", "mio", "parking_lot", + "serde", "signal-hook", "signal-hook-mio", "winapi", @@ -402,22 +403,11 @@ dependencies = [ "chrono", ] -[[package]] -name = "displaydoc" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "either" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "encoding_rs" @@ -727,9 +717,9 @@ dependencies = [ [[package]] name = "httparse" -version = "1.9.3" +version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0e7a4dd27b9476dc40cb050d3632d3bba3a70ddbff012285f7f8559a1e7e545" +checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" [[package]] name = "httpdate" @@ -849,124 +839,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "icu_collections" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locid" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" - -[[package]] -name = "icu_normalizer" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "utf16_iter", - "utf8_iter", - "write16", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" - -[[package]] -name = "icu_properties" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f8ac670d7422d7f76b32e17a5db556510825b29ec9154f235977c9caba61036" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locid_transform", - "icu_properties_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" - -[[package]] -name = "icu_provider" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", - "writeable", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "ident_case" version = "1.0.1" @@ -975,14 +847,12 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4716a3a0933a1d01c2f72450e89596eb51dd34ef3c211ccd875acdf1f8fe47ed" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" dependencies = [ - "icu_normalizer", - "icu_properties", - "smallvec", - "utf8_iter", + "unicode-bidi", + "unicode-normalization", ] [[package]] @@ -1043,12 +913,6 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" -[[package]] -name = "litemap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" - [[package]] name = "lock_api" version = "0.4.12" @@ -1061,9 +925,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.21" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "lru" @@ -1101,9 +965,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "miniz_oxide" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87dfd01fe195c66b572b37921ad8803d010623c0aca821bea2302239d155cdae" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" dependencies = [ "adler", ] @@ -1154,9 +1018,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.0" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "576dfe1fc8f9df304abb159d767a29d0476f7750fbf8aa7ad07816004a207434" +checksum = "081b846d1d56ddfc18fdf1a922e4f6e07a11768ea1b92dec44e42b72712ccfce" dependencies = [ "memchr", ] @@ -1173,7 +1037,7 @@ version = "0.10.64" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "cfg-if", "foreign-types", "libc", @@ -1292,9 +1156,9 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "proc-macro2" -version = "1.0.85" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" dependencies = [ "unicode-ident", ] @@ -1401,7 +1265,7 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "cassowary", "compact_str", "crossterm", @@ -1422,7 +1286,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", ] [[package]] @@ -1563,13 +1427,23 @@ name = "rm-config" version = "0.3.3" dependencies = [ "anyhow", + "crossterm", "ratatui", + "rm-shared", "serde", + "thiserror", "toml", "url", "xdg", ] +[[package]] +name = "rm-shared" +version = "0.3.3" +dependencies = [ + "crossterm", +] + [[package]] name = "rss" version = "2.0.8" @@ -1600,7 +1474,7 @@ version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", @@ -1694,6 +1568,7 @@ dependencies = [ "regex", "reqwest 0.12.5", "rm-config", + "rm-shared", "rss", "serde", "throbber-widgets-tui", @@ -1747,7 +1622,7 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "core-foundation", "core-foundation-sys", "libc", @@ -1786,9 +1661,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.117" +version = "1.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" +checksum = "d947f6b3163d8857ea16c4fa0dd4840d52f3041039a85decd46867eb1abef2e4" dependencies = [ "itoa", "ryu", @@ -1898,12 +1773,6 @@ dependencies = [ "syn", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - [[package]] name = "static_assertions" version = "1.1.0" @@ -1918,9 +1787,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ "strum_macros", ] @@ -1940,15 +1809,15 @@ dependencies = [ [[package]] name = "subtle" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d0208408ba0c3df17ed26eb06992cb1a1268d41b2c0e12e65203fbe3972cee5" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.66" +version = "2.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" +checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9" dependencies = [ "proc-macro2", "quote", @@ -1967,17 +1836,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" -[[package]] -name = "synstructure" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "system-configuration" version = "0.5.1" @@ -2051,21 +1909,11 @@ dependencies = [ "ratatui", ] -[[package]] -name = "tinystr" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "tinyvec" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +checksum = "c55115c6fbe2d2bef26eb09ad74bde02d8255476fc0c7b515ef09fbb35742d82" dependencies = [ "tinyvec_macros", ] @@ -2278,12 +2126,27 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.11.0" @@ -2314,27 +2177,16 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.1" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c25da092f0a868cdf09e8674cd3b7ef3a7d92a24253e663a2fb85e2496de56" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "utf8parse" version = "0.2.2" @@ -2649,48 +2501,12 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - -[[package]] -name = "writeable" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" - [[package]] name = "xdg" version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" -[[package]] -name = "yoke" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - [[package]] name = "zerocopy" version = "0.7.34" @@ -2711,51 +2527,8 @@ dependencies = [ "syn", ] -[[package]] -name = "zerofrom" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" - -[[package]] -name = "zerovec" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2cc8827d6c0994478a15c53f374f46fbd41bea663d809b14744bc42e6b109c" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97cf56601ee5052b4417d90c8755c6683473c926039908196cf35d99f893ebe7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/Cargo.toml b/Cargo.toml index 4487e0b..a71ada2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ license = "GPL-3.0-or-later" [workspace.dependencies] rm-config = { version = "0.3", path = "rm-config" } +rm-shared = { version = "0.3", path = "rm-shared" } magnetease = "0.1" anyhow = "1" @@ -24,11 +25,12 @@ fuzzy-matcher = "0.3.7" clap = { version = "4.5.6", features = ["derive"] } base64 = "0.22" xdg = "2.5" -url = "2.5" +url = { version = "2.5", features = ["serde"] } toml = "0.8" rss = "2" reqwest = "0.12" regex = "1" +thiserror = "1" # Async tokio = { version = "1", features = ["macros", "sync"] } @@ -36,7 +38,7 @@ tokio-util = "0.7" futures = "0.3" # TUI -crossterm = { version = "0.27", features = ["event-stream"] } +crossterm = { version = "0.27", features = ["event-stream", "serde"] } ratatui = { version = "0.26", features = ["serde"] } tui-input = "0.8" tui-tree-widget = "0.20" diff --git a/rm-config/Cargo.toml b/rm-config/Cargo.toml index a519736..e1ab5f9 100644 --- a/rm-config/Cargo.toml +++ b/rm-config/Cargo.toml @@ -11,11 +11,12 @@ homepage.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +rm-shared.workspace = true xdg.workspace = true toml.workspace = true serde.workspace = true anyhow.workspace = true url.workspace = true ratatui.workspace = true - - +crossterm.workspace = true +thiserror.workspace = true diff --git a/rm-config/defaults/keymap.toml b/rm-config/defaults/keymap.toml new file mode 100644 index 0000000..26272db --- /dev/null +++ b/rm-config/defaults/keymap.toml @@ -0,0 +1,46 @@ +[general] +keybindings = [ + { on = "?", action = "ShowHelp" }, + { on = "F1", action = "ShowHelp" }, + + { on = "q", action = "Quit" }, + { on = "Esc", action = "Close" }, + { on = "Enter", action = "Confirm" }, + { on = " ", action = "Select" }, + { on = "Tab", action = "SwitchFocus" }, + { on = "/", action = "Search" }, + + { on = "1", action = "SwitchToTorrents" }, + { on = "2", action = "SwitchToSearch" }, + + { on = "Home", action = "GoToBeginning" }, + { on = "End", action = "GoToEnd" }, + { on = "PageUp", action = "ScrollPageUp" }, + { on = "PageDown", action = "ScrollPageDown" }, + + { modifier = "Ctrl", on = "u", action = "ScrollPageUp" }, + { modifier = "Ctrl", on = "d", action = "ScrollPageDown" }, + + # Arrows + { on = "Left", action = "Left" }, + { on = "Right", action = "Right" }, + { on = "Up", action = "Up"}, + { on = "Down", action = "Down" }, + + # Vi + { on = "h", action = "Left" }, + { on = "l", action = "Right" }, + { on = "k", action = "Up" }, + { on = "j", action = "Down" }, +] + +[torrents_tab] +keybindings = [ + { on = "a", action = "AddMagnet" }, + { on = "p", action = "Pause" }, + { on = "f", action = "ShowFiles" }, + { on = "s", action = "ShowStats" }, + + { on = "d", action = "DeleteWithoutFiles" }, + { on = "D", action = "DeleteWithFiles" }, +] diff --git a/rm-config/src/keymap/actions/general.rs b/rm-config/src/keymap/actions/general.rs new file mode 100644 index 0000000..de3bff7 --- /dev/null +++ b/rm-config/src/keymap/actions/general.rs @@ -0,0 +1,73 @@ +use rm_shared::action::Action; +use serde::{Deserialize, Serialize}; + +use super::UserAction; + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum GeneralAction { + ShowHelp, + Quit, + Close, + SwitchToTorrents, + SwitchToSearch, + Left, + Right, + Down, + Up, + Search, + SwitchFocus, + Confirm, + Select, + ScrollPageDown, + ScrollPageUp, + GoToBeginning, + GoToEnd, +} + +impl UserAction for GeneralAction { + fn desc(&self) -> &'static str { + match self { + GeneralAction::ShowHelp => "toggle help", + GeneralAction::Quit => "quit Rustmission / a popup", + GeneralAction::Close => "close a popup / task", + GeneralAction::SwitchToTorrents => "switch to torrents tab", + GeneralAction::SwitchToSearch => "switch to search tab", + GeneralAction::Left => "switch to tab left", + GeneralAction::Right => "switch to tab right", + GeneralAction::Down => "move down", + GeneralAction::Up => "move up", + GeneralAction::Search => "search", + GeneralAction::SwitchFocus => "switch focus", + GeneralAction::Confirm => "confirm", + GeneralAction::Select => "select", + GeneralAction::ScrollPageDown => "scroll page down", + GeneralAction::ScrollPageUp => "scroll page up", + GeneralAction::GoToBeginning => "scroll to the beginning", + GeneralAction::GoToEnd => "scroll to the end", + } + } +} + +impl From for Action { + fn from(value: GeneralAction) -> Self { + match value { + GeneralAction::ShowHelp => Action::ShowHelp, + GeneralAction::Quit => Action::Quit, + GeneralAction::Close => Action::Close, + GeneralAction::SwitchToTorrents => Action::ChangeTab(1), + GeneralAction::SwitchToSearch => Action::ChangeTab(2), + GeneralAction::Left => Action::Left, + GeneralAction::Right => Action::Right, + GeneralAction::Down => Action::Down, + GeneralAction::Up => Action::Up, + GeneralAction::Search => Action::Search, + GeneralAction::SwitchFocus => Action::ChangeFocus, + GeneralAction::Confirm => Action::Confirm, + GeneralAction::Select => Action::Select, + GeneralAction::ScrollPageDown => Action::ScrollDownPage, + GeneralAction::ScrollPageUp => Action::ScrollUpPage, + GeneralAction::GoToBeginning => Action::Home, + GeneralAction::GoToEnd => Action::End, + } + } +} diff --git a/rm-config/src/keymap/actions/mod.rs b/rm-config/src/keymap/actions/mod.rs new file mode 100644 index 0000000..76131db --- /dev/null +++ b/rm-config/src/keymap/actions/mod.rs @@ -0,0 +1,8 @@ +use rm_shared::action::Action; + +pub mod general; +pub mod torrents_tab; + +pub trait UserAction: Into { + fn desc(&self) -> &'static str; +} diff --git a/rm-config/src/keymap/actions/torrents_tab.rs b/rm-config/src/keymap/actions/torrents_tab.rs new file mode 100644 index 0000000..c1b39a7 --- /dev/null +++ b/rm-config/src/keymap/actions/torrents_tab.rs @@ -0,0 +1,40 @@ +use rm_shared::action::Action; +use serde::{Deserialize, Serialize}; + +use super::UserAction; + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum TorrentsAction { + AddMagnet, + Pause, + DeleteWithFiles, + DeleteWithoutFiles, + ShowFiles, + ShowStats, +} + +impl UserAction for TorrentsAction { + fn desc(&self) -> &'static str { + match self { + TorrentsAction::AddMagnet => "add a magnet", + TorrentsAction::Pause => "pause/unpause", + TorrentsAction::DeleteWithFiles => "delete with files", + TorrentsAction::DeleteWithoutFiles => "delete without files", + TorrentsAction::ShowFiles => "show files", + TorrentsAction::ShowStats => "show statistics", + } + } +} + +impl From for Action { + fn from(value: TorrentsAction) -> Self { + match value { + TorrentsAction::AddMagnet => Action::AddMagnet, + TorrentsAction::Pause => Action::Pause, + TorrentsAction::DeleteWithFiles => Action::DeleteWithFiles, + TorrentsAction::DeleteWithoutFiles => Action::DeleteWithoutFiles, + TorrentsAction::ShowFiles => Action::ShowFiles, + TorrentsAction::ShowStats => Action::ShowStats, + } + } +} diff --git a/rm-config/src/keymap/mod.rs b/rm-config/src/keymap/mod.rs new file mode 100644 index 0000000..7f8526b --- /dev/null +++ b/rm-config/src/keymap/mod.rs @@ -0,0 +1,319 @@ +pub mod actions; + +use std::{ + collections::HashMap, io::ErrorKind, marker::PhantomData, path::PathBuf, sync::OnceLock, +}; + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyModifiers as CrosstermKeyModifiers}; +use serde::{ + de::{self, Visitor}, + Deserialize, Serialize, +}; + +use crate::utils; +use rm_shared::action::Action; + +use self::actions::{general::GeneralAction, torrents_tab::TorrentsAction}; + +#[derive(Serialize, Deserialize, Clone)] +pub struct KeymapConfig { + pub general: KeybindsHolder, + pub torrents_tab: KeybindsHolder, + #[serde(skip)] + pub keymap: HashMap<(KeyCode, CrosstermKeyModifiers), Action>, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct KeybindsHolder> { + pub keybindings: Vec>, +} + +#[derive(Serialize, Clone)] +pub struct Keybinding> { + pub on: KeyCode, + #[serde(default)] + pub modifier: KeyModifier, + pub action: T, +} + +impl> Keybinding { + pub fn keycode_string(&self) -> String { + let key = match self.on { + KeyCode::Backspace => "Backspace".into(), + KeyCode::Enter => "Enter".into(), + KeyCode::Left => "".into(), + KeyCode::Right => "".into(), + KeyCode::Up => "".into(), + KeyCode::Down => "".into(), + KeyCode::Home => "Home".into(), + KeyCode::End => "End".into(), + KeyCode::PageUp => "PageUp".into(), + KeyCode::PageDown => "PageDown".into(), + KeyCode::Tab => "Tab".into(), + KeyCode::BackTab => todo!(), + KeyCode::Delete => todo!(), + KeyCode::Insert => "Insert".into(), + KeyCode::F(i) => format!("F{i}"), + KeyCode::Char(c) => { + if c == ' ' { + "Space".into() + } else { + c.into() + } + } + KeyCode::Null => todo!(), + KeyCode::Esc => "Esc".into(), + KeyCode::CapsLock => todo!(), + KeyCode::ScrollLock => todo!(), + KeyCode::NumLock => todo!(), + KeyCode::PrintScreen => todo!(), + KeyCode::Pause => todo!(), + KeyCode::Menu => todo!(), + KeyCode::KeypadBegin => todo!(), + KeyCode::Media(_) => todo!(), + KeyCode::Modifier(_) => todo!(), + }; + + if !self.modifier.is_none() { + format!("{}-{key}", self.modifier.to_str()) + } else { + key + } + } +} + +impl> Keybinding { + fn new(on: KeyCode, action: T, modifier: Option) -> Self { + Self { + on, + modifier: modifier.unwrap_or(KeyModifier::None), + action, + } + } +} + +impl<'de, T: Into + Deserialize<'de>> Deserialize<'de> for Keybinding { + fn deserialize(deserializer: D) -> std::prelude::v1::Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(field_identifier, rename_all = "lowercase")] + enum Field { + On, + Modifier, + Action, + } + + struct KeybindingVisitor { + phantom: PhantomData, + } + + impl<'de, T: Into + Deserialize<'de>> Visitor<'de> for KeybindingVisitor { + type Value = Keybinding; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("struct Keybinding") + } + + fn visit_map(self, mut map: A) -> std::prelude::v1::Result + where + A: serde::de::MapAccess<'de>, + { + let mut on = None; + let mut modifier = None; + let mut action = None; + while let Some(key) = map.next_key()? { + match key { + Field::On => { + if on.is_some() { + return Err(de::Error::duplicate_field("on")); + } + let key = map.next_value::()?; + + if key.len() == 1 { + on = Some(KeyCode::Char(key.chars().next().unwrap())); + } else if key.starts_with('F') && (key.len() == 2 || key.len() == 3) { + let which_f = key[1..].parse::().map_err(|_| { + de::Error::invalid_value( + de::Unexpected::Str(&key), + &"something_correct", + ) + })?; + on = Some(KeyCode::F(which_f)); + } else { + on = { + match key.to_lowercase().as_str() { + "enter" => Some(KeyCode::Enter), + "esc" => Some(KeyCode::Esc), + "up" => Some(KeyCode::Up), + "down" => Some(KeyCode::Down), + "left" => Some(KeyCode::Left), + "right" => Some(KeyCode::Right), + "home" => Some(KeyCode::Home), + "end" => Some(KeyCode::End), + "pageup" => Some(KeyCode::PageUp), + "pagedown" => Some(KeyCode::PageDown), + "tab" => Some(KeyCode::Tab), + "backspace" => Some(KeyCode::Backspace), + "delete" => Some(KeyCode::Delete), + + _ => { + return Err(de::Error::invalid_value( + de::Unexpected::Str(&key), + &"something correct", + )) + } + } + }; + } + } + Field::Modifier => { + if modifier.is_some() { + return Err(de::Error::duplicate_field("modifier")); + } + modifier = Some(map.next_value()); + } + Field::Action => { + if action.is_some() { + return Err(de::Error::duplicate_field("action")); + } + action = Some(map.next_value()); + } + } + } + let on = on.ok_or_else(|| de::Error::missing_field("on"))?; + let action = action.ok_or_else(|| de::Error::missing_field("action"))??; + let modifier = modifier.transpose().unwrap(); + + if modifier.is_some() { + if let KeyCode::Char(char) = on { + if char.is_uppercase() { + return Err(de::Error::custom( + "you can't have a modifier with an uppercase letter, sorry", + )); + } + } + } + + Ok(Keybinding::new(on, action, modifier)) + } + } + + const FIELDS: &[&str] = &["on", "modifier", "action"]; + deserializer.deserialize_struct( + "Keybinding", + FIELDS, + KeybindingVisitor { + phantom: PhantomData::default(), + }, + ) + } +} + +#[derive(Serialize, Deserialize, Hash, Clone, Copy, PartialEq, Eq)] +pub enum KeyModifier { + None, + Ctrl, + Shift, + Alt, + Super, + Meta, +} + +impl KeyModifier { + fn to_str(self) -> &'static str { + match self { + KeyModifier::None => "", + KeyModifier::Ctrl => "CTRL", + KeyModifier::Shift => "SHIFT", + KeyModifier::Alt => "ALT", + KeyModifier::Super => "SUPER", + KeyModifier::Meta => "META", + } + } + + fn is_none(self) -> bool { + self == KeyModifier::None + } +} + +impl From for CrosstermKeyModifiers { + fn from(value: KeyModifier) -> Self { + match value { + KeyModifier::None => CrosstermKeyModifiers::NONE, + KeyModifier::Ctrl => CrosstermKeyModifiers::CONTROL, + KeyModifier::Shift => CrosstermKeyModifiers::SHIFT, + KeyModifier::Alt => CrosstermKeyModifiers::ALT, + KeyModifier::Super => CrosstermKeyModifiers::SUPER, + KeyModifier::Meta => CrosstermKeyModifiers::META, + } + } +} + +impl Default for KeyModifier { + fn default() -> Self { + Self::None + } +} + +impl KeymapConfig { + pub const FILENAME: &'static str = "keymap.toml"; + const DEFAULT_CONFIG: &'static str = include_str!("../../defaults/keymap.toml"); + + pub fn init() -> Result { + match utils::fetch_config::(Self::FILENAME) { + Ok(mut keymap_config) => { + keymap_config.populate_hashmap(); + return Ok(keymap_config); + } + Err(e) => match e { + utils::ConfigFetchingError::Io(e) if e.kind() == ErrorKind::NotFound => { + let mut keymap_config = + utils::put_config::(Self::DEFAULT_CONFIG, Self::FILENAME)?; + keymap_config.populate_hashmap(); + return Ok(keymap_config); + } + _ => anyhow::bail!(e), + }, + } + } + + pub fn get_keys_for_action(&self, action: Action) -> Option { + let mut keys = vec![]; + + for keybinding in &self.general.keybindings { + if action == keybinding.action.into() { + keys.push(keybinding.keycode_string()); + } + } + for keybinding in &self.torrents_tab.keybindings { + if action == keybinding.action.into() { + keys.push(keybinding.keycode_string()); + } + } + + if keys.is_empty() { + return None; + } else { + Some(keys.join("/")) + } + } + + fn populate_hashmap(&mut self) { + for keybinding in &self.general.keybindings { + let hash_value = (keybinding.on, keybinding.modifier.into()); + self.keymap.insert(hash_value, keybinding.action.into()); + } + for keybinding in &self.torrents_tab.keybindings { + let hash_value = (keybinding.on, keybinding.modifier.into()); + self.keymap.insert(hash_value, keybinding.action.into()); + } + } + + pub fn path() -> &'static PathBuf { + static PATH: OnceLock = OnceLock::new(); + PATH.get_or_init(|| utils::get_config_path(Self::FILENAME)) + } +} diff --git a/rm-config/src/lib.rs b/rm-config/src/lib.rs index 6e8f85b..a2e3a8b 100644 --- a/rm-config/src/lib.rs +++ b/rm-config/src/lib.rs @@ -1,171 +1,40 @@ -use std::{ - fs::File, - io::{Read, Write}, - path::PathBuf, - sync::OnceLock, -}; +pub mod keymap; +mod main_config; +mod utils; -use anyhow::{bail, Context, Result}; -use ratatui::style::Color; -use serde::{Deserialize, Serialize}; -use toml::Table; -use xdg::BaseDirectories; +use std::path::PathBuf; -#[derive(Debug, Serialize, Deserialize)] -pub struct Config { - pub connection: Connection, - pub general: General, -} +use anyhow::Result; +use keymap::KeymapConfig; +use main_config::MainConfig; -#[derive(Debug, Serialize, Deserialize)] -pub struct General { - #[serde(default)] - pub auto_hide: bool, - #[serde(default = "default_accent_color")] - pub accent_color: Color, - #[serde(default = "default_beginner_mode")] - pub beginner_mode: bool, - #[serde(default)] - pub headers_hide: bool, -} - -fn default_accent_color() -> Color { - Color::LightMagenta -} - -fn default_beginner_mode() -> bool { - true +pub struct Config { + pub general: main_config::General, + pub connection: main_config::Connection, + pub keybindings: KeymapConfig, + pub directories: Directories, } -#[derive(Debug, Serialize, Deserialize)] -pub struct Connection { - pub username: Option, - pub password: Option, - pub url: String, - #[serde(default)] - pub torrents_refresh: u64, - #[serde(default)] - pub stats_refresh: u64, - #[serde(default)] - pub free_space_refresh: u64, +pub struct Directories { + pub main_path: &'static PathBuf, + pub keymap_path: &'static PathBuf, } -const DEFAULT_CONFIG: &str = include_str!("../defaults/config.toml"); -static XDG_DIRS: OnceLock = OnceLock::new(); -static CONFIG_PATH: OnceLock = OnceLock::new(); - impl Config { pub fn init() -> Result { - let Ok(table) = Self::table_from_home() else { - Self::put_default_conf_in_home()?; - // TODO: check if the user really changed the config. - println!( - "Update {:?} and start rustmission again", - Self::get_config_path() - ); - std::process::exit(0); - }; + let main_config = MainConfig::init()?; + let keybindings = KeymapConfig::init()?; - Self::table_config_verify(&table)?; - - Self::table_to_config(&table) - } - - fn table_from_home() -> Result { - let xdg_dirs = xdg::BaseDirectories::with_prefix("rustmission")?; - let config_path = xdg_dirs - .find_config_file("config.toml") - .ok_or_else(|| anyhow::anyhow!("config.toml not found"))?; - - let mut config_buf = String::new(); - let mut config_file = File::open(config_path).unwrap(); - config_file.read_to_string(&mut config_buf).unwrap(); - Ok(toml::from_str(&config_buf)?) - } - - fn put_default_conf_in_home() -> Result
{ - let config_path = Self::get_config_path(); - let mut config_file = File::create(config_path)?; - config_file.write_all(DEFAULT_CONFIG.as_bytes())?; - Ok(toml::from_str(DEFAULT_CONFIG)?) - } - - fn table_to_config(table: &Table) -> Result { - let config_string = table.to_string(); - let config: Self = toml::from_str(&config_string)?; - Ok(config) - } - - fn table_config_verify(table: &Table) -> Result<()> { - let Some(connection_table) = table.get("connection").unwrap().as_table() else { - bail!("expected connection table") + let directories = Directories { + main_path: MainConfig::path(), + keymap_path: KeymapConfig::path(), }; - let url = connection_table - .get("url") - .and_then(|url| url.as_str()) - .with_context(|| { - format!( - "no url given in: {}", - Self::get_config_path().to_str().unwrap() - ) - })?; - - url::Url::parse(url).with_context(|| { - format!( - "invalid url '{url}' in {}", - Self::get_config_path().to_str().unwrap() - ) - })?; - - Ok(()) - } - - fn get_xdg_dirs() -> &'static BaseDirectories { - XDG_DIRS.get_or_init(|| xdg::BaseDirectories::with_prefix("rustmission").unwrap()) - } - - pub fn get_config_path() -> &'static PathBuf { - CONFIG_PATH.get_or_init(|| { - Self::get_xdg_dirs() - .place_config_file("config.toml") - .unwrap() + Ok(Self { + general: main_config.general, + connection: main_config.connection, + keybindings: keybindings.clone(), + directories, }) } } - -#[cfg(test)] -mod tests { - use super::*; - - fn invalid_config() -> Table { - toml::toml! { - [connection] - username = "username" - password = "password" - auto_hide = "dfgoij" - url = "bad_url" - } - } - - fn valid_config() -> Table { - toml::toml! { - [connection] - username = "username" - password = "password" - url = "http://192.168.1.1/transmission/rpc" - } - } - - #[test] - fn validates_properly() { - let valid_config = valid_config(); - assert!(Config::table_config_verify(&valid_config).is_ok()); - } - - #[test] - fn invalidates_properly() { - let invalid_config = invalid_config(); - assert!(Config::table_config_verify(&invalid_config).is_err()); - } -} diff --git a/rm-config/src/main_config.rs b/rm-config/src/main_config.rs new file mode 100644 index 0000000..68e1f3c --- /dev/null +++ b/rm-config/src/main_config.rs @@ -0,0 +1,68 @@ +use std::{path::PathBuf, sync::OnceLock}; + +use anyhow::Result; +use ratatui::style::Color; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::utils::{self, put_config}; + +#[derive(Serialize, Deserialize)] +pub struct MainConfig { + pub general: General, + pub connection: Connection, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct General { + #[serde(default)] + pub auto_hide: bool, + #[serde(default = "default_accent_color")] + pub accent_color: Color, + #[serde(default = "default_beginner_mode")] + pub beginner_mode: bool, + #[serde(default)] + pub headers_hide: bool, +} + +fn default_accent_color() -> Color { + Color::LightMagenta +} + +fn default_beginner_mode() -> bool { + true +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Connection { + pub username: Option, + pub password: Option, + pub url: Url, + #[serde(default)] + pub torrents_refresh: u64, + #[serde(default)] + pub stats_refresh: u64, + #[serde(default)] + pub free_space_refresh: u64, +} + +impl MainConfig { + pub(crate) const FILENAME: &'static str = "config.toml"; + const DEFAULT_CONFIG: &'static str = include_str!("../defaults/config.toml"); + + pub(crate) fn init() -> Result { + let Ok(config) = utils::fetch_config(Self::FILENAME) else { + put_config(Self::DEFAULT_CONFIG, Self::FILENAME)?; + // TODO: check if the user really changed the config. + println!("Update {:?} and start rustmission again", Self::path()); + std::process::exit(0); + }; + + Ok(config) + } + + pub(crate) fn path() -> &'static PathBuf { + static PATH: OnceLock = OnceLock::new(); + PATH.get_or_init(|| utils::get_config_path(Self::FILENAME)) + } +} diff --git a/rm-config/src/utils.rs b/rm-config/src/utils.rs new file mode 100644 index 0000000..102afde --- /dev/null +++ b/rm-config/src/utils.rs @@ -0,0 +1,50 @@ +use std::{ + fs::File, + io::{self, Read, Write}, + path::PathBuf, + sync::OnceLock, +}; + +use anyhow::Result; +use serde::de::DeserializeOwned; +use thiserror::Error; +use xdg::BaseDirectories; + +#[derive(Error, Debug)] +pub enum ConfigFetchingError { + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Toml(#[from] toml::de::Error), +} + +pub fn xdg_dirs() -> &'static BaseDirectories { + static XDG_DIRS: OnceLock = OnceLock::new(); + XDG_DIRS.get_or_init(|| xdg::BaseDirectories::with_prefix("rustmission").unwrap()) +} + +pub fn get_config_path(filename: &str) -> PathBuf { + xdg_dirs().place_config_file(filename).unwrap() +} + +pub fn fetch_config(config_name: &str) -> Result { + let config_path = xdg_dirs().find_config_file(config_name).ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, format!("{config_name} not found")) + })?; + + let mut config_buf = String::new(); + let mut config_file = File::open(config_path)?; + config_file.read_to_string(&mut config_buf)?; + + Ok(toml::from_str(&config_buf)?) +} + +pub fn put_config( + content: &'static str, + filename: &str, +) -> Result { + let config_path = get_config_path(filename); + let mut config_file = File::create(config_path)?; + config_file.write_all(content.as_bytes())?; + Ok(toml::from_str(content).expect("default configs are correct")) +} diff --git a/rm-main/Cargo.toml b/rm-main/Cargo.toml index 2dcb188..d518e11 100644 --- a/rm-main/Cargo.toml +++ b/rm-main/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" [dependencies] rm-config.workspace = true +rm-shared.workspace = true magnetease.workspace = true anyhow.workspace = true serde.workspace = true diff --git a/rm-main/src/action.rs b/rm-main/src/action.rs deleted file mode 100644 index d3a8a76..0000000 --- a/rm-main/src/action.rs +++ /dev/null @@ -1,118 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - -use crate::{tui::Event, ui::global_popups::ErrorPopup}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum Action { - HardQuit, - Quit, - SoftQuit, - Render, - Tick, - Up, - Down, - Left, - Right, - ScrollDownPage, - ScrollUpPage, - Home, - End, - Confirm, - Space, - ShowHelp, - ShowStats, - ShowFiles, - Search, - Pause, - DeleteWithoutFiles, - DeleteWithFiles, - SwitchToInputMode, - SwitchToNormalMode, - ChangeFocus, - AddMagnet, - ChangeTab(u8), - Input(KeyEvent), - Error(Box), -} - -impl Action { - pub fn is_render(&self) -> bool { - *self == Self::Render - } - - pub fn is_quit(&self) -> bool { - *self == Self::HardQuit || *self == Self::Quit - } - - pub fn is_soft_quit(&self) -> bool { - self.is_quit() || *self == Self::SoftQuit - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum Mode { - Input, - Normal, -} - -pub fn event_to_action(mode: Mode, event: Event) -> Option { - use Action as A; - - // Handle CTRL+C first - if let Event::Key(key_event) = event { - if key_event.modifiers == KeyModifiers::CONTROL - && (key_event.code == KeyCode::Char('c') || key_event.code == KeyCode::Char('C')) - { - return Some(A::HardQuit); - } - } - - match event { - Event::Quit => Some(A::Quit), - Event::Error => todo!(), - Event::Render => Some(A::Render), - Event::Key(key) if mode == Mode::Input => Some(A::Input(key)), - Event::Key(key) => key_event_to_action(key), - } -} - -fn key_event_to_action(key: KeyEvent) -> Option { - use Action as A; - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('d')) => Some(A::ScrollDownPage), - (KeyModifiers::CONTROL, KeyCode::Char('u')) => Some(A::ScrollUpPage), - (_, keycode) => keycode_to_action(keycode), - } -} - -fn keycode_to_action(key: KeyCode) -> Option { - use Action as A; - match key { - KeyCode::Char('q') | KeyCode::Char('Q') => Some(A::Quit), - KeyCode::Esc => Some(A::SoftQuit), - KeyCode::Tab => Some(A::ChangeFocus), - KeyCode::Home => Some(A::Home), - KeyCode::End => Some(A::End), - KeyCode::PageUp => Some(A::ScrollUpPage), - KeyCode::PageDown => Some(A::ScrollDownPage), - KeyCode::Char('j') | KeyCode::Down => Some(A::Down), - KeyCode::Char('k') | KeyCode::Up => Some(A::Up), - KeyCode::Char('h') | KeyCode::Left => Some(A::Left), - KeyCode::Char('l') | KeyCode::Right => Some(A::Right), - KeyCode::Char('?') | KeyCode::F(1) => Some(A::ShowHelp), - KeyCode::Char('s') => Some(A::ShowStats), - KeyCode::Char('f') => Some(A::ShowFiles), - KeyCode::Char('/') => Some(A::Search), - KeyCode::Char('a') => Some(A::AddMagnet), - KeyCode::Char('p') => Some(A::Pause), - KeyCode::Char('d') => Some(A::DeleteWithoutFiles), - KeyCode::Char('D') => Some(A::DeleteWithFiles), - KeyCode::Char(' ') => Some(A::Space), - KeyCode::Char(n @ '1'..='9') => { - Some(A::ChangeTab(n.to_digit(10).expect("This is ok") as u8)) - } - KeyCode::Enter => Some(A::Confirm), - _ => None, - } -} diff --git a/rm-main/src/app.rs b/rm-main/src/app.rs index 15f1c6f..c187bee 100644 --- a/rm-main/src/app.rs +++ b/rm-main/src/app.rs @@ -1,8 +1,10 @@ use rm_config::Config; +use rm_shared::action::event_to_action; +use rm_shared::action::Action; +use rm_shared::action::Mode; use std::sync::Arc; use crate::{ - action::{event_to_action, Action, Mode}, transmission::{self, TorrentAction}, tui::Tui, ui::{components::Component, MainWindow}, @@ -44,9 +46,10 @@ impl Ctx { }); } Err(e) => { - let config_path = Config::get_config_path().to_str().unwrap(); + let config_path = config.directories.main_path; return Err(Error::msg(format!( - "{e}\nIs the connection info in {config_path} correct?" + "{e}\nIs the connection info in {:?} correct?", + config_path ))); } } @@ -114,7 +117,7 @@ impl App { }, event = tui_event => { - if let Some(action) = event_to_action(self.mode, event.unwrap()) { + if let Some(action) = event_to_action(self.mode, event.unwrap(), &self.ctx.config.keybindings.keymap) { if let Some(action) = self.update(action).await { self.ctx.action_tx.send(action).unwrap(); } diff --git a/rm-main/src/cli.rs b/rm-main/src/cli.rs index 0b37c35..fc6f78a 100644 --- a/rm-main/src/cli.rs +++ b/rm-main/src/cli.rs @@ -41,6 +41,11 @@ async fn add_torrent(config: &Config, torrent: String) -> Result<()> { filename: Some(torrent), ..Default::default() } + } else if torrent.starts_with("www") { + TorrentAddArgs { + filename: Some(format!("https://{torrent}")), + ..Default::default() + } } else { let mut torrent_file = File::open(torrent)?; let mut buf = vec![]; diff --git a/rm-main/src/main.rs b/rm-main/src/main.rs index 7168fa1..a55d156 100644 --- a/rm-main/src/main.rs +++ b/rm-main/src/main.rs @@ -1,4 +1,3 @@ -mod action; pub mod app; mod cli; pub mod transmission; diff --git a/rm-main/src/transmission/action.rs b/rm-main/src/transmission/action.rs index 6d49760..8266593 100644 --- a/rm-main/src/transmission/action.rs +++ b/rm-main/src/transmission/action.rs @@ -5,7 +5,9 @@ use transmission_rpc::types::{ Id, SessionGet, Torrent, TorrentAction as RPCAction, TorrentAddArgs, TorrentSetArgs, }; -use crate::{action::Action, app, ui::global_popups::ErrorPopup}; +use crate::app; +use rm_shared::action::Action; +use rm_shared::action::ErrorMessage; #[derive(Debug)] pub enum TorrentAction { @@ -25,20 +27,34 @@ pub async fn action_handler(ctx: app::Ctx, mut trans_rx: UnboundedReceiver { + let formatted = { + if url.starts_with("www") { + format!("https://{url}") + } else { + url.to_string() + } + }; let args = TorrentAddArgs { - filename: Some(url.clone()), + filename: Some(formatted), download_dir: directory, ..Default::default() }; - - if let Err(e) = ctx.client.lock().await.torrent_add(args).await { - let error_title = "Failed to add a torrent"; - let msg = "Failed to add torrent with URL/Path:\n\"".to_owned() - + url - + "\"\n" - + &e.to_string(); - let error_popup = Box::new(ErrorPopup::new(error_title, msg)); - ctx.send_action(Action::Error(error_popup)); + match ctx.client.lock().await.torrent_add(args).await { + Ok(_) => { + ctx.send_action(Action::TaskSuccess); + } + Err(e) => { + let error_title = "Failed to add a torrent"; + let msg = "Failed to add torrent with URL/Path:\n\"".to_owned() + + url + + "\"\n" + + &e.to_string(); + let error_message = ErrorMessage { + title: error_title.to_string(), + message: msg, + }; + ctx.send_action(Action::Error(Box::new(error_message))); + } } } TorrentAction::Stop(ids) => { @@ -64,6 +80,7 @@ pub async fn action_handler(ctx: app::Ctx, mut trans_rx: UnboundedReceiver { ctx.client @@ -72,6 +89,7 @@ pub async fn action_handler(ctx: app::Ctx, mut trans_rx: UnboundedReceiver { let new_torrent_info = ctx diff --git a/rm-main/src/transmission/fetchers.rs b/rm-main/src/transmission/fetchers.rs index 4f65726..51c0029 100644 --- a/rm-main/src/transmission/fetchers.rs +++ b/rm-main/src/transmission/fetchers.rs @@ -6,10 +6,10 @@ use std::{ use transmission_rpc::types::{FreeSpace, SessionStats, TorrentGetField}; use crate::{ - action::Action, app, ui::tabs::torrents::{rustmission_torrent::RustmissionTorrent, table_manager::TableManager}, }; +use rm_shared::action::Action; pub async fn stats(ctx: app::Ctx, stats: Arc>>) { loop { diff --git a/rm-main/src/transmission/utils.rs b/rm-main/src/transmission/utils.rs index 27fc0ef..f8d9b0f 100644 --- a/rm-main/src/transmission/utils.rs +++ b/rm-main/src/transmission/utils.rs @@ -14,9 +14,8 @@ pub fn client_from_config(config: &Config) -> TransClient { .as_ref() .unwrap_or(&"".to_string()) .clone(); - let url = config.connection.url.parse().unwrap(); let auth = BasicAuth { user, password }; - TransClient::with_auth(url, auth) + TransClient::with_auth(config.connection.url.clone(), auth) } diff --git a/rm-main/src/tui.rs b/rm-main/src/tui.rs index 31a80ec..7149f03 100644 --- a/rm-main/src/tui.rs +++ b/rm-main/src/tui.rs @@ -1,9 +1,9 @@ -use std::time::Duration; +use std::{io, time::Duration}; use anyhow::Result; use crossterm::{ cursor, - event::{Event as CrosstermEvent, KeyEvent, KeyEventKind}, + event::{Event, KeyEventKind}, terminal::{EnterAlternateScreen, LeaveAlternateScreen}, }; use futures::{FutureExt, StreamExt}; @@ -14,17 +14,9 @@ use tokio::{ }; use tokio_util::sync::CancellationToken; -#[derive(Clone, Debug)] -pub enum Event { - Quit, - Error, - Render, - Key(KeyEvent), -} - pub struct Tui { pub terminal: ratatui::Terminal>, - pub task: JoinHandle<()>, + pub task: JoinHandle>, pub cancellation_token: CancellationToken, pub event_rx: UnboundedReceiver, pub event_tx: UnboundedSender, @@ -35,7 +27,7 @@ impl Tui { let terminal = ratatui::Terminal::new(Backend::new(std::io::stdout()))?; let (event_tx, event_rx) = mpsc::unbounded_channel(); let cancellation_token = CancellationToken::new(); - let task = tokio::spawn(async {}); + let task = tokio::spawn(async { Ok(()) }); Ok(Self { terminal, task, @@ -45,7 +37,7 @@ impl Tui { }) } - pub fn start(&mut self) { + pub fn start(&mut self) -> Result<()> { self.cancel(); self.cancellation_token = CancellationToken::new(); let cancellation_token = self.cancellation_token.clone(); @@ -57,26 +49,29 @@ impl Tui { let crossterm_event = reader.next().fuse(); tokio::select! { _ = cancellation_token.cancelled() => break, - event = crossterm_event => Self::handle_crossterm_event(event, &event_tx), + event = crossterm_event => Self::handle_crossterm_event::(event, &event_tx)?, } } + Ok(()) }); + Ok(()) } fn handle_crossterm_event( - event: Option>, + event: Option>, event_tx: &UnboundedSender, - ) { + ) -> Result<()> { match event { - Some(Ok(CrosstermEvent::Key(key))) => { + Some(Ok(Event::Key(key))) => { if key.kind == KeyEventKind::Press { event_tx.send(Event::Key(key)).unwrap(); } } - Some(Ok(CrosstermEvent::Resize(_, _))) => event_tx.send(Event::Render).unwrap(), - Some(Err(_)) => event_tx.send(Event::Error).unwrap(), + Some(Ok(Event::Resize(x, y))) => event_tx.send(Event::Resize(x, y)).unwrap(), + Some(Err(e)) => Err(e)?, _ => (), } + Ok(()) } pub(crate) fn stop(&self) { @@ -97,7 +92,7 @@ impl Tui { pub(crate) fn enter(&mut self) -> Result<()> { crossterm::terminal::enable_raw_mode()?; crossterm::execute!(std::io::stdout(), EnterAlternateScreen, cursor::Hide)?; - self.start(); + self.start()?; Ok(()) } diff --git a/rm-main/src/ui/components/mod.rs b/rm-main/src/ui/components/mod.rs index fb4eb91..8d85944 100644 --- a/rm-main/src/ui/components/mod.rs +++ b/rm-main/src/ui/components/mod.rs @@ -4,7 +4,7 @@ pub mod tabs; use ratatui::prelude::*; use ratatui::Frame; -use crate::action::Action; +use rm_shared::action::Action; pub use tabs::TabComponent; pub trait Component { diff --git a/rm-main/src/ui/components/tabs.rs b/rm-main/src/ui/components/tabs.rs index 0a56e85..293d4a4 100644 --- a/rm-main/src/ui/components/tabs.rs +++ b/rm-main/src/ui/components/tabs.rs @@ -1,4 +1,5 @@ -use crate::{action::Action, app}; +use crate::app; +use rm_shared::action::Action; use super::Component; use ratatui::{layout::Flex, prelude::*, widgets::Tabs}; diff --git a/rm-main/src/ui/global_popups/error.rs b/rm-main/src/ui/global_popups/error.rs index bedeca3..faf0ec1 100644 --- a/rm-main/src/ui/global_popups/error.rs +++ b/rm-main/src/ui/global_popups/error.rs @@ -3,10 +3,8 @@ use ratatui::{ widgets::{Block, Clear, Paragraph, Wrap}, }; -use crate::{ - action::Action, - ui::{centered_rect, components::Component}, -}; +use crate::ui::{centered_rect, components::Component}; +use rm_shared::action::Action; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ErrorPopup { @@ -16,7 +14,7 @@ pub struct ErrorPopup { } impl ErrorPopup { - pub fn new(title: &'static str, message: String) -> Self { + pub fn new(title: &str, message: String) -> Self { Self { title: title.to_owned(), message, @@ -28,7 +26,7 @@ impl Component for ErrorPopup { fn handle_actions(&mut self, action: Action) -> Option { match action { _ if action.is_soft_quit() => Some(action), - Action::Confirm => Some(Action::SoftQuit), + Action::Confirm => Some(Action::Close), _ => None, } } diff --git a/rm-main/src/ui/global_popups/help.rs b/rm-main/src/ui/global_popups/help.rs index 2b06f52..71d003b 100644 --- a/rm-main/src/ui/global_popups/help.rs +++ b/rm-main/src/ui/global_popups/help.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use ratatui::{ prelude::*, widgets::{ @@ -7,10 +9,11 @@ use ratatui::{ }; use crate::{ - action::Action, app, ui::{centered_rect, components::Component}, }; +use rm_config::keymap::{actions::UserAction, Keybinding}; +use rm_shared::action::Action; macro_rules! add_line { ($lines:expr, $key:expr, $description:expr) => { @@ -30,13 +33,31 @@ impl HelpPopup { pub const fn new(ctx: app::Ctx) -> Self { Self { ctx } } + + fn write_keybindings + UserAction + Ord>( + keybindings: &[Keybinding], + lines: &mut Vec, + ) { + let mut keys = BTreeMap::new(); + + for keybinding in keybindings { + keys.entry(&keybinding.action) + .or_insert_with(Vec::new) + .push(keybinding.keycode_string()); + } + + for (action, keycodes) in keys { + let keycode_string = keycodes.join(" / "); + add_line!(lines, keycode_string, action.desc()); + } + } } impl Component for HelpPopup { fn handle_actions(&mut self, action: Action) -> Option { match action { - action if action.is_soft_quit() => Some(Action::SoftQuit), - Action::Confirm | Action::ShowHelp => Some(Action::SoftQuit), + action if action.is_soft_quit() => Some(Action::Close), + Action::Confirm | Action::ShowHelp => Some(Action::Close), _ => None, } } @@ -67,22 +88,7 @@ impl Component for HelpPopup { )]) .centered()]; - add_line!(lines, "? / F1", "show/hide help"); - add_line!(lines, "q", "quit Rustmission / a popup"); - add_line!(lines, "ESC", "close a popup / task"); - add_line!(lines, "1", "switch to torrents tab"); - add_line!(lines, "2", "switch to search tab"); - add_line!(lines, "h / ←", "switch to tab left of current tab"); - add_line!(lines, "l / →", "switch to tab right of current tab"); - add_line!(lines, "j / ↓", "move down"); - add_line!(lines, "k / ↑", "move up"); - add_line!(lines, "/", "search or filter"); - add_line!(lines, "TAB", "switch focus"); - add_line!(lines, "Enter", "confirm"); - add_line!(lines, "CTRL-d", "scroll page down"); - add_line!(lines, "CTRL-u", "scroll page up"); - add_line!(lines, "Home", "scroll to the beginning"); - add_line!(lines, "End", "scroll to the end"); + Self::write_keybindings(&self.ctx.config.keybindings.general.keybindings, &mut lines); lines.push( Line::from(vec![Span::styled( @@ -92,12 +98,10 @@ impl Component for HelpPopup { .centered(), ); - add_line!(lines, "a", "add a magnet url"); - add_line!(lines, "p", "pause/unpause a torrent"); - add_line!(lines, "d", "delete a torrent without files"); - add_line!(lines, "D", "delete a torrent with files"); - add_line!(lines, "f", "show files of a torrent"); - add_line!(lines, "s", "show statistics"); + Self::write_keybindings( + &self.ctx.config.keybindings.torrents_tab.keybindings, + &mut lines, + ); let help_text = Text::from(lines); let help_paragraph = Paragraph::new(help_text); diff --git a/rm-main/src/ui/global_popups/mod.rs b/rm-main/src/ui/global_popups/mod.rs index 91cf956..2ea2007 100644 --- a/rm-main/src/ui/global_popups/mod.rs +++ b/rm-main/src/ui/global_popups/mod.rs @@ -6,7 +6,8 @@ use ratatui::prelude::*; pub use error::ErrorPopup; pub use help::HelpPopup; -use crate::{action::Action, app}; +use crate::app; +use rm_shared::action::Action; use super::components::Component; diff --git a/rm-main/src/ui/mod.rs b/rm-main/src/ui/mod.rs index 3db2bde..43954ab 100644 --- a/rm-main/src/ui/mod.rs +++ b/rm-main/src/ui/mod.rs @@ -2,16 +2,15 @@ pub mod components; pub mod global_popups; pub mod tabs; -use crate::ui::tabs::torrents::TorrentsTab; +use crate::ui::{global_popups::ErrorPopup, tabs::torrents::TorrentsTab}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::prelude::*; use tui_input::InputRequest; -use crate::{ - action::Action, - app::{self}, -}; +use rm_shared::action::Action; + +use crate::app::{self}; use self::{ components::{tabs::CurrentTab, Component, TabComponent}, @@ -45,8 +44,9 @@ impl Component for MainWindow { use Action as A; match action { - A::Error(e_popup) => { - self.global_popup_manager.error_popup = Some(*e_popup); + A::Error(error) => { + let error_popup = ErrorPopup::new(&error.title, error.message); + self.global_popup_manager.error_popup = Some(error_popup); Some(A::Render) } A::ShowHelp => self.global_popup_manager.handle_actions(action), diff --git a/rm-main/src/ui/tabs/search.rs b/rm-main/src/ui/tabs/search.rs index 96ff320..be53710 100644 --- a/rm-main/src/ui/tabs/search.rs +++ b/rm-main/src/ui/tabs/search.rs @@ -15,7 +15,6 @@ use tokio::sync::mpsc::{self, UnboundedSender}; use tui_input::Input; use crate::{ - action::Action, app, transmission::TorrentAction, ui::{ @@ -24,6 +23,7 @@ use crate::{ }, utils::bytes_to_human_format, }; +use rm_shared::action::Action; #[derive(Clone, Copy, PartialEq, Eq)] enum SearchFocus { diff --git a/rm-main/src/ui/tabs/torrents/input_manager.rs b/rm-main/src/ui/tabs/torrents/input_manager.rs index 93ff5ae..ba8cdd8 100644 --- a/rm-main/src/ui/tabs/torrents/input_manager.rs +++ b/rm-main/src/ui/tabs/torrents/input_manager.rs @@ -4,7 +4,8 @@ use ratatui::{ }; use tui_input::{Input, InputRequest}; -use crate::{action::Action, app, ui::components::Component}; +use crate::{app, ui::components::Component}; +use rm_shared::action::Action; pub struct InputManager { input: Input, diff --git a/rm-main/src/ui/tabs/torrents/mod.rs b/rm-main/src/ui/tabs/torrents/mod.rs index b498ffe..167cb75 100644 --- a/rm-main/src/ui/tabs/torrents/mod.rs +++ b/rm-main/src/ui/tabs/torrents/mod.rs @@ -15,10 +15,10 @@ use ratatui::prelude::*; use ratatui::widgets::{Row, Table}; use transmission_rpc::types::TorrentStatus; -use crate::action::Action; use crate::ui::components::table::GenericTable; use crate::ui::components::Component; use crate::{app, transmission}; +use rm_shared::action::Action; use self::bottom_stats::BottomStats; use self::popups::files::FilesPopup; diff --git a/rm-main/src/ui/tabs/torrents/popups/files.rs b/rm-main/src/ui/tabs/torrents/popups/files.rs index 0bfce3d..7806fce 100644 --- a/rm-main/src/ui/tabs/torrents/popups/files.rs +++ b/rm-main/src/ui/tabs/torrents/popups/files.rs @@ -15,11 +15,11 @@ use transmission_rpc::types::{Id, Torrent, TorrentSetArgs}; use tui_tree_widget::{Tree, TreeItem, TreeState}; use crate::{ - action::Action, app, transmission::TorrentAction, ui::{centered_rect, components::Component}, }; +use rm_shared::action::Action; pub struct FilesPopup { ctx: app::Ctx, @@ -111,7 +111,7 @@ impl Component for FilesPopup { Some(A::Render) } (A::Confirm, CurrentFocus::CloseButton) => Some(A::Quit), - (A::Space | A::Confirm, CurrentFocus::Files) => { + (A::Select | A::Confirm, CurrentFocus::Files) => { if let Some(torrent) = &mut *self.torrent.lock().unwrap() { let wanted_ids = torrent.wanted.as_mut().unwrap(); diff --git a/rm-main/src/ui/tabs/torrents/popups/mod.rs b/rm-main/src/ui/tabs/torrents/popups/mod.rs index 6b784d6..398f57e 100644 --- a/rm-main/src/ui/tabs/torrents/popups/mod.rs +++ b/rm-main/src/ui/tabs/torrents/popups/mod.rs @@ -1,5 +1,6 @@ use self::{files::FilesPopup, stats::StatisticsPopup}; -use crate::{action::Action, ui::components::Component}; +use crate::ui::components::Component; +use rm_shared::action::Action; use ratatui::prelude::*; diff --git a/rm-main/src/ui/tabs/torrents/popups/stats.rs b/rm-main/src/ui/tabs/torrents/popups/stats.rs index db846f7..3c02123 100644 --- a/rm-main/src/ui/tabs/torrents/popups/stats.rs +++ b/rm-main/src/ui/tabs/torrents/popups/stats.rs @@ -8,11 +8,11 @@ use ratatui::{ use transmission_rpc::types::SessionStats; use crate::{ - action::Action, app, ui::{centered_rect, components::Component}, utils::bytes_to_human_format, }; +use rm_shared::action::Action; pub struct StatisticsPopup { stats: SessionStats, @@ -30,7 +30,7 @@ impl Component for StatisticsPopup { use Action as A; match action { _ if action.is_soft_quit() => Some(action), - A::Confirm => Some(Action::SoftQuit), + A::Confirm => Some(Action::Close), _ => None, } } diff --git a/rm-main/src/ui/tabs/torrents/task_manager.rs b/rm-main/src/ui/tabs/torrents/task_manager.rs index cbe4888..eab8171 100644 --- a/rm-main/src/ui/tabs/torrents/task_manager.rs +++ b/rm-main/src/ui/tabs/torrents/task_manager.rs @@ -1,15 +1,18 @@ use std::sync::{Arc, Mutex}; use ratatui::prelude::*; +use throbber_widgets_tui::ThrobberState; -use crate::{action::Action, app, ui::components::Component}; +use crate::{app, ui::components::Component}; +use rm_shared::{action::Action, status_task::StatusTask}; use super::{ tasks::{ add_magnet::AddMagnetBar, default::DefaultBar, - delete_torrent::{self, DeleteBar}, + delete_torrent::{self, DeleteBar, TorrentInfo}, filter::FilterBar, + status::{CurrentTaskState, StatusBar}, }, TableManager, }; @@ -30,11 +33,22 @@ impl TaskManager { } } -enum CurrentTask { +pub enum CurrentTask { AddMagnetBar(AddMagnetBar), DeleteBar(DeleteBar), FilterBar(FilterBar), Default(DefaultBar), + Status(StatusBar), +} + +impl CurrentTask { + fn tick(&mut self) -> Option { + if let Self::Status(status_bar) = self { + status_bar.tick() + } else { + None + } + } } impl Component for TaskManager { @@ -43,20 +57,46 @@ impl Component for TaskManager { use Action as A; match &mut self.current_task { CurrentTask::AddMagnetBar(magnet_bar) => match magnet_bar.handle_actions(action) { - Some(A::Quit) => self.finish_task(), + Some(A::TaskPending(task)) => self.pending_task(task), + Some(A::Quit) => self.cancel_task(), Some(A::Render) => Some(A::Render), _ => None, }, CurrentTask::DeleteBar(delete_bar) => match delete_bar.handle_actions(action) { - Some(A::Quit) => self.finish_task(), + Some(A::TaskPending(task)) => { + let selected = self + .table_manager + .lock() + .unwrap() + .table + .state + .borrow() + .selected(); + + // select closest existing torrent + if let Some(idx) = selected { + if idx > 0 { + self.table_manager.lock().unwrap().table.previous(); + } + } + self.pending_task(task) + } + Some(A::Quit) => self.cancel_task(), Some(A::Render) => Some(A::Render), _ => None, }, CurrentTask::FilterBar(filter_bar) => match filter_bar.handle_actions(action) { - Some(A::Quit) => self.finish_task(), + Some(A::Quit) => self.cancel_task(), + Some(A::Render) => Some(A::Render), + _ => None, + }, + + CurrentTask::Status(status_bar) => match status_bar.handle_actions(action) { + Some(A::Quit) => self.cancel_task(), Some(A::Render) => Some(A::Render), + Some(action) => self.handle_events_to_manager(&action), _ => None, }, @@ -70,8 +110,13 @@ impl Component for TaskManager { CurrentTask::DeleteBar(delete_bar) => delete_bar.render(f, rect), CurrentTask::FilterBar(filter_bar) => filter_bar.render(f, rect), CurrentTask::Default(default_bar) => default_bar.render(f, rect), + CurrentTask::Status(status_bar) => status_bar.render(f, rect), } } + + fn tick(&mut self) -> Option { + self.current_task.tick() + } } impl TaskManager { @@ -99,7 +144,10 @@ impl TaskManager { if let Some(torrent) = self.table_manager.lock().unwrap().current_torrent() { self.current_task = CurrentTask::DeleteBar(DeleteBar::new( self.ctx.clone(), - vec![torrent.id.clone()], + vec![TorrentInfo { + id: torrent.id.clone(), + name: torrent.torrent_name.clone(), + }], mode, )); Some(Action::SwitchToInputMode) @@ -108,12 +156,21 @@ impl TaskManager { } } - fn finish_task(&mut self) -> Option { - if !matches!(self.current_task, CurrentTask::Default(_)) { - self.current_task = CurrentTask::Default(DefaultBar::new(self.ctx.clone())); - Some(Action::SwitchToNormalMode) - } else { - None + fn pending_task(&mut self, task: StatusTask) -> Option { + if matches!(self.current_task, CurrentTask::Status(_)) { + return None; + } + let state = Arc::new(Mutex::new(ThrobberState::default())); + self.current_task = + CurrentTask::Status(StatusBar::new(task, CurrentTaskState::Loading(state))); + Some(Action::SwitchToNormalMode) + } + + fn cancel_task(&mut self) -> Option { + if matches!(self.current_task, CurrentTask::Default(_)) { + return None; } + self.current_task = CurrentTask::Default(DefaultBar::new(self.ctx.clone())); + Some(Action::SwitchToNormalMode) } } diff --git a/rm-main/src/ui/tabs/torrents/tasks/add_magnet.rs b/rm-main/src/ui/tabs/torrents/tasks/add_magnet.rs index 4840752..0688d75 100644 --- a/rm-main/src/ui/tabs/torrents/tasks/add_magnet.rs +++ b/rm-main/src/ui/tabs/torrents/tasks/add_magnet.rs @@ -2,11 +2,11 @@ use crossterm::event::{KeyCode, KeyEvent}; use ratatui::prelude::*; use crate::{ - action::Action, app, transmission::TorrentAction, ui::{components::Component, tabs::torrents::input_manager::InputManager, to_input_request}, }; +use rm_shared::{action::Action, status_task::StatusTask}; pub struct AddMagnetBar { input_magnet_mgr: InputManager, @@ -25,7 +25,7 @@ impl AddMagnetBar { Self { input_magnet_mgr: InputManager::new( ctx.clone(), - "Add (Magnet URL/ Torrent path): ".to_string(), + "Add (Magnet URL / Torrent path): ".to_string(), ), input_location_mgr: InputManager::new_with_value( ctx.clone(), @@ -66,7 +66,9 @@ impl AddMagnetBar { self.input_magnet_mgr.text(), Some(self.input_location_mgr.text()), )); - return Some(Action::Quit); + return Some(Action::TaskPending(StatusTask::Add( + self.input_magnet_mgr.text(), + ))); } if input.code == KeyCode::Esc { return Some(Action::Quit); diff --git a/rm-main/src/ui/tabs/torrents/tasks/default.rs b/rm-main/src/ui/tabs/torrents/tasks/default.rs index 201e26b..f508350 100644 --- a/rm-main/src/ui/tabs/torrents/tasks/default.rs +++ b/rm-main/src/ui/tabs/torrents/tasks/default.rs @@ -15,7 +15,14 @@ impl DefaultBar { impl Component for DefaultBar { fn render(&mut self, f: &mut ratatui::Frame<'_>, rect: Rect) { if self.ctx.config.general.beginner_mode { - f.render_widget("F1 - help", rect) + if let Some(keys) = self + .ctx + .config + .keybindings + .get_keys_for_action(rm_shared::action::Action::ShowHelp) + { + f.render_widget(format!("󰘥 {keys} - help"), rect) + } } } } diff --git a/rm-main/src/ui/tabs/torrents/tasks/delete_torrent.rs b/rm-main/src/ui/tabs/torrents/tasks/delete_torrent.rs index 5a1dc59..bbd4f1c 100644 --- a/rm-main/src/ui/tabs/torrents/tasks/delete_torrent.rs +++ b/rm-main/src/ui/tabs/torrents/tasks/delete_torrent.rs @@ -3,14 +3,21 @@ use ratatui::prelude::*; use transmission_rpc::types::Id; use crate::{ - action::Action, app, transmission::TorrentAction, ui::{components::Component, tabs::torrents::input_manager::InputManager, to_input_request}, }; +use rm_shared::action::Action; +use rm_shared::status_task::StatusTask; + +#[derive(Clone)] +pub struct TorrentInfo { + pub id: Id, + pub name: String, +} pub struct DeleteBar { - torrents_to_delete: Vec, + torrents_to_delete: Vec, ctx: app::Ctx, input_mgr: InputManager, mode: Mode, @@ -22,7 +29,7 @@ pub enum Mode { } impl DeleteBar { - pub fn new(ctx: app::Ctx, to_delete: Vec, mode: Mode) -> Self { + pub fn new(ctx: app::Ctx, to_delete: Vec, mode: Mode) -> Self { let prompt = { match mode { Mode::WithFiles => "Really delete selected WITH files? (y/n) ".to_string(), @@ -50,7 +57,11 @@ impl Component for DeleteBar { if input.code == KeyCode::Enter { let text = self.input_mgr.text().to_lowercase(); if text == "y" || text == "yes" { - let torrents_to_delete = self.torrents_to_delete.clone(); + let torrents_to_delete: Vec = self + .torrents_to_delete + .iter() + .map(|x| x.id.clone()) + .collect(); match self.mode { Mode::WithFiles => self.ctx.send_torrent_action( TorrentAction::DeleteWithFiles(torrents_to_delete), @@ -62,7 +73,9 @@ impl Component for DeleteBar { )) } } - return Some(Action::Quit); + return Some(Action::TaskPending(StatusTask::Delete( + self.torrents_to_delete[0].name.clone(), + ))); } else if text == "n" || text == "no" { return Some(Action::Quit); } diff --git a/rm-main/src/ui/tabs/torrents/tasks/filter.rs b/rm-main/src/ui/tabs/torrents/tasks/filter.rs index f58cecc..8854a65 100644 --- a/rm-main/src/ui/tabs/torrents/tasks/filter.rs +++ b/rm-main/src/ui/tabs/torrents/tasks/filter.rs @@ -4,7 +4,6 @@ use crossterm::event::KeyCode; use ratatui::prelude::*; use crate::{ - action::Action, app, ui::{ components::Component, @@ -12,6 +11,7 @@ use crate::{ to_input_request, }, }; +use rm_shared::action::Action; pub struct FilterBar { input: InputManager, diff --git a/rm-main/src/ui/tabs/torrents/tasks/mod.rs b/rm-main/src/ui/tabs/torrents/tasks/mod.rs index 269b3e5..9dbc1de 100644 --- a/rm-main/src/ui/tabs/torrents/tasks/mod.rs +++ b/rm-main/src/ui/tabs/torrents/tasks/mod.rs @@ -2,3 +2,4 @@ pub mod add_magnet; pub mod default; pub mod delete_torrent; pub mod filter; +pub mod status; diff --git a/rm-main/src/ui/tabs/torrents/tasks/status.rs b/rm-main/src/ui/tabs/torrents/tasks/status.rs new file mode 100644 index 0000000..efc9da3 --- /dev/null +++ b/rm-main/src/ui/tabs/torrents/tasks/status.rs @@ -0,0 +1,142 @@ +use std::sync::{Arc, Mutex}; + +use crate::ui::components::Component; + +use ratatui::{prelude::*, widgets::Paragraph}; +use rm_shared::{action::Action, status_task::StatusTask}; +use throbber_widgets_tui::ThrobberState; +use tokio::time::{self, Instant}; + +pub struct StatusBar { + task: StatusTask, + pub task_status: CurrentTaskState, +} + +impl StatusBar { + pub const fn new(task: StatusTask, task_status: CurrentTaskState) -> Self { + Self { task, task_status } + } +} + +fn format_display_name(name: &str) -> String { + if name.len() < 60 { + name.to_string() + } else { + let truncated = &name[0..59]; + format!("\"{truncated}...\"") + } +} + +impl Component for StatusBar { + fn render(&mut self, f: &mut Frame, rect: Rect) { + match &self.task_status { + CurrentTaskState::Loading(state) => { + let status_text = match &self.task { + StatusTask::Add(name) => { + let display_name = format_display_name(&name); + format!("Adding {display_name}") + } + StatusTask::Delete(name) => { + let display_name = format_display_name(&name); + format!("Deleting {display_name}") + } + }; + let default_throbber = throbber_widgets_tui::Throbber::default() + .label(status_text) + .style(ratatui::style::Style::default().fg(ratatui::style::Color::Yellow)); + f.render_stateful_widget( + default_throbber.clone(), + rect, + &mut state.lock().unwrap(), + ); + } + task_state => { + let status_text = match task_state { + CurrentTaskState::Failure => match &self.task { + StatusTask::Add(name) => { + let display_name = format_display_name(&name); + format!(" Error adding {display_name}") + } + StatusTask::Delete(name) => { + let display_name = format_display_name(&name); + format!(" Error deleting {display_name}") + } + }, + CurrentTaskState::Success(_) => match &self.task { + StatusTask::Add(name) => { + let display_name = format_display_name(&name); + format!(" Added {display_name}") + } + StatusTask::Delete(name) => { + let display_name = format_display_name(&name); + format!(" Deleted {display_name}") + } + }, + _ => return, + }; + let mut line = Line::default(); + match task_state { + CurrentTaskState::Failure => { + line.push_span(Span::styled("", Style::default().red())); + } + CurrentTaskState::Success(_) => { + line.push_span(Span::styled("", Style::default().green())); + } + _ => return, + } + line.push_span(Span::raw(status_text)); + let paragraph = Paragraph::new(line); + f.render_widget(paragraph, rect); + } + } + } + + fn handle_actions(&mut self, action: Action) -> Option { + match action { + Action::Tick => self.tick(), + Action::TaskSuccess => { + self.task_status.set_success(time::Instant::now()); + Some(Action::Render) + } + Action::Error(_) => { + self.task_status.set_failure(); + Some(Action::Render) + } + _ => Some(action), + } + } + + fn tick(&mut self) -> Option { + match &self.task_status { + CurrentTaskState::Loading(state) => { + state.lock().unwrap().calc_next(); + Some(Action::Render) + } + CurrentTaskState::Success(start) => { + let expiration_duration = time::Duration::from_secs(5); + if start.elapsed() >= expiration_duration { + return Some(Action::Quit); + } + None + } + _ => None, + } + } +} + +#[derive(Clone)] +pub enum CurrentTaskState { + Loading(Arc>), + Success(Instant), + Failure, +} + +impl CurrentTaskState { + fn set_failure(&mut self) { + *self = Self::Failure; + } + + fn set_success(&mut self, start: Instant) { + *self = Self::Success(start); + } +} diff --git a/rm-shared/Cargo.toml b/rm-shared/Cargo.toml new file mode 100644 index 0000000..66fa6de --- /dev/null +++ b/rm-shared/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "rm-shared" +version.workspace = true +edition.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +license.workspace = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +crossterm.workspace = true diff --git a/rm-shared/src/action.rs b/rm-shared/src/action.rs new file mode 100644 index 0000000..867f135 --- /dev/null +++ b/rm-shared/src/action.rs @@ -0,0 +1,96 @@ +use std::collections::HashMap; + +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + +use crate::status_task::StatusTask; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + HardQuit, + Quit, + Close, + Tick, + Render, + Up, + Down, + Left, + Right, + ScrollDownPage, + ScrollUpPage, + Home, + End, + Confirm, + Select, + ShowHelp, + ShowStats, + ShowFiles, + Search, + Pause, + DeleteWithoutFiles, + DeleteWithFiles, + SwitchToInputMode, + SwitchToNormalMode, + ChangeFocus, + AddMagnet, + ChangeTab(u8), + Input(KeyEvent), + Error(Box), + TaskPending(StatusTask), + TaskSuccess, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ErrorMessage { + pub title: String, + pub message: String, +} + +impl Action { + pub fn is_render(&self) -> bool { + *self == Self::Render + } + + pub fn is_quit(&self) -> bool { + *self == Self::HardQuit || *self == Self::Quit + } + + pub fn is_soft_quit(&self) -> bool { + self.is_quit() || *self == Self::Close + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Input, + Normal, +} + +pub fn event_to_action( + mode: Mode, + event: Event, + keymap: &HashMap<(KeyCode, KeyModifiers), Action>, +) -> Option { + use Action as A; + + // Handle CTRL+C first + if let Event::Key(key_event) = event { + if key_event.modifiers == KeyModifiers::CONTROL + && (key_event.code == KeyCode::Char('c') || key_event.code == KeyCode::Char('C')) + { + return Some(A::HardQuit); + } + } + + match event { + Event::Key(key) if mode == Mode::Input => Some(A::Input(key)), + Event::Key(key) => { + if let KeyCode::Char(e) = key.code { + if e.is_uppercase() { + return keymap.get(&(key.code, KeyModifiers::NONE)).cloned(); + } + } + keymap.get(&(key.code, key.modifiers)).cloned() + } + _ => None, + } +} diff --git a/rm-shared/src/lib.rs b/rm-shared/src/lib.rs new file mode 100644 index 0000000..2136505 --- /dev/null +++ b/rm-shared/src/lib.rs @@ -0,0 +1,2 @@ +pub mod action; +pub mod status_task; diff --git a/rm-shared/src/status_task.rs b/rm-shared/src/status_task.rs new file mode 100644 index 0000000..c550e11 --- /dev/null +++ b/rm-shared/src/status_task.rs @@ -0,0 +1,5 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StatusTask { + Add(String), + Delete(String), +}