diff --git a/.gitignore b/.gitignore index 92162c1..27ce7e1 100755 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ hac.log tarpaulin-report.html mutants.out* +.vscode diff --git a/Cargo.lock b/Cargo.lock index f1b2a8c..c1358e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,21 @@ version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.14" @@ -177,6 +192,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.6", +] + [[package]] name = "clap" version = "4.5.15" @@ -196,7 +225,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim 0.11.1", + "strsim", "terminal_size", ] @@ -300,41 +329,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "darling" -version = "0.20.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.10.0", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" -dependencies = [ - "darling_core", - "quote", - "syn", -] - [[package]] name = "deranged" version = "0.3.11" @@ -344,37 +338,6 @@ dependencies = [ "powerfmt", ] -[[package]] -name = "derive_builder" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0350b5cb0331628a5916d6c5c0b72e97393b8b6b03b47a9284f4e7f5a405ffd7" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d48cda787f839151732d396ac69e3473923d54312c070ee21e9effcaa8ca0b1d" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "206868b8242f27cecce124c19fd88157fbd0dd334df2587f36417bafbc85097b" -dependencies = [ - "derive_builder_core", - "syn", -] - [[package]] name = "dirs" version = "5.0.1" @@ -450,16 +413,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" [[package]] -name = "fnv" -version = "1.0.7" +name = "filetime" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] [[package]] -name = "font8x8" -version = "0.3.1" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875488b8711a968268c7cf5d139578713097ca4635a76044e8fe8eedf831d07e" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "form_urlencoded" @@ -470,6 +439,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.30" @@ -604,6 +582,8 @@ dependencies = [ "hac-colors", "hac-config", "hac-core", + "hac-loader", + "hac-store", "lazy_static", "mutants", "rand", @@ -616,7 +596,6 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "tree-sitter", - "tui-big-text", "uuid", ] @@ -649,6 +628,7 @@ version = "0.2.0" dependencies = [ "anyhow", "hac-config", + "hac-store", "jsonxf", "lazy_static", "ratatui", @@ -662,6 +642,29 @@ dependencies = [ "tree-sitter-json", ] +[[package]] +name = "hac-loader" +version = "0.2.0" +dependencies = [ + "anyhow", + "dirs", + "hac-config", + "hac-store", + "notify", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "hac-store" +version = "0.2.0" +dependencies = [ + "chrono", + "serde", + "tracing", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -787,10 +790,27 @@ dependencies = [ ] [[package]] -name = "ident_case" -version = "1.0.1" +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] [[package]] name = "idna" @@ -812,6 +832,26 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.9.0" @@ -867,6 +907,26 @@ dependencies = [ "memchr", ] +[[package]] +name = "kqueue" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -887,6 +947,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.5.0", "libc", + "redox_syscall", ] [[package]] @@ -971,6 +1032,25 @@ version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.5.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -987,6 +1067,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -1383,6 +1472,15 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.23" @@ -1566,12 +1664,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9557cb6521e8d009c51a8666f09356f4b817ba9ba0981a305bd86aee47bd35c" -[[package]] -name = "strsim" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" - [[package]] name = "strsim" version = "0.11.1" @@ -1920,18 +2012,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tui-big-text" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74e81324f07ec6df239679d17120e479b09a29a564df88f7ae2e1979ecc717a8" -dependencies = [ - "derive_builder", - "font8x8", - "itertools 0.12.1", - "ratatui", -] - [[package]] name = "unicode-bidi" version = "0.3.15" @@ -2033,6 +2113,16 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2140,12 +2230,30 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index d8e388c..45c3ab3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,13 @@ [workspace] -members = ["hac-config", "hac-core", "hac-client", "hac-colors", "hac-cli"] +members = [ + "hac-config", + "hac-core", + "hac-client", + "hac-colors", + "hac-cli", + "hac-store", + "hac-loader", +] default-members = ["hac-client"] resolver = "2" @@ -10,10 +18,12 @@ name = "hac" version = "0.2.0" [workspace.dependencies] +hac-store = { path = "hac-store" } hac-config = { path = "hac-config" } hac-core = { path = "hac-core" } hac-colors = { path = "hac-colors" } hac-cli = { path = "hac-cli" } +hac-loader = { path = "hac-loader" } anyhow = "1.0.81" crossterm = { version = "0.27.0", features = ["event-stream"] } diff --git a/Justfile b/Justfile index 5e868e8..1f52997 100755 --- a/Justfile +++ b/Justfile @@ -20,7 +20,7 @@ build-time: cargo +nightly build -Z timings fmt: - cargo fmt --check + cargo +nightly fmt --check lint: cargo clippy -- -D warnings @@ -130,4 +130,3 @@ cross: ### x86 just binary hac.exe x86_64-pc-windows-gnu just binary_static hac.exe x86_64-pc-windows-gnu - diff --git a/TODO.md b/TODO.md index 770b9a7..0919e57 100755 --- a/TODO.md +++ b/TODO.md @@ -15,3 +15,16 @@ - [ ] edit bodies in $EDITOR - [ ] add scripting to requests - [ ] export requests to other formats + + +```rust + +struct AppRouter { + routers: Vec, +} + +struct Router { + routes: HashMap>, +} + +``` diff --git a/hac-client/Cargo.toml b/hac-client/Cargo.toml index 4a831c2..8d831bb 100644 --- a/hac-client/Cargo.toml +++ b/hac-client/Cargo.toml @@ -18,6 +18,8 @@ hac-core.workspace = true hac-config.workspace = true hac-colors.workspace = true hac-cli.workspace = true +hac-loader.workspace = true +hac-store.workspace = true anyhow.workspace = true crossterm.workspace = true @@ -31,7 +33,6 @@ divan.workspace = true lazy_static.workspace = true futures = "0.3.30" -tui-big-text = { version = "0.4.3" } tracing-subscriber = { version = "0.3.18" } tracing-appender = "0.2.3" uuid = { version = "1.10.0", features = ["v4", "fast-rng", "macro-diagnostics"] } @@ -46,7 +47,3 @@ name = "hac" test = false bench = false path = "src/main.rs" - -[[bench]] -name = "collection_viewer_bench" -harness = false diff --git a/hac-client/benches/collection_viewer_bench.rs b/hac-client/benches/collection_viewer_bench.rs deleted file mode 100755 index 6af468b..0000000 --- a/hac-client/benches/collection_viewer_bench.rs +++ /dev/null @@ -1,156 +0,0 @@ -use hac_core::collection::types::{BodyType, Info, Request, RequestKind, RequestMethod}; -use hac_core::collection::Collection; -use hac_core::syntax::highlighter::Highlighter; - -use hac_client::pages::collection_viewer::{collection_store::CollectionStore, CollectionViewer}; -use hac_client::pages::{Eventful, Renderable}; -use hac_client::utils::build_syntax_highlighted_lines; - -use std::cell::RefCell; -use std::rc::Rc; -use std::sync::{Arc, RwLock}; - -use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; -use lazy_static::lazy_static; -use ratatui::{backend::TestBackend, layout::Rect, Terminal}; -use tree_sitter::Tree; - -fn main() { - divan::main(); -} - -fn create_sample_collection() -> Collection { - Collection { - info: Info { - name: "sample collection".to_string(), - description: None, - }, - path: "any_path".into(), - requests: Some(Arc::new(RwLock::new(vec![ - RequestKind::Single(Arc::new(RwLock::new(Request { - id: "any id".to_string(), - headers: None, - name: "testing".to_string(), - parent: None, - auth_method: None, - uri: "https://jsonplaceholder.typicode.com/users".to_string(), - method: RequestMethod::Get, - body: Some("[\r\n {\r\n \"id\": 1,\r\n \"name\": \"Leanne Graham\",\r\n \"username\": \"Bret\",\r\n \"email\": \"Sincere@april.biz\",\r\n \"address\": {\r\n \"street\": \"Kulas Light\",\r\n \"suite\": \"Apt. 556\",\r\n \"city\": \"Gwenborough\",\r\n \"zipcode\": \"92998-3874\",\r\n \"geo\": {\r\n \"lat\": \"-37.3159\",\r\n \"lng\": \"81.1496\"\r\n }\r\n },\r\n \"phone\": \"1-770-736-8031 x56442\",\r\n \"website\": \"hildegard.org\",\r\n \"company\": {\r\n \"name\": \"Romaguera-Crona\",\r\n \"catchPhrase\": \"Multi-layered client-server neural-net\",\r\n \"bs\": \"harness real-time e-markets\"\r\n }\r\n },\r\n {\r\n \"id\": 2,\r\n \"name\": \"Ervin Howell\",\r\n \"username\": \"Antonette\",\r\n \"email\": \"Shanna@melissa.tv\",\r\n \"address\": {\r\n \"street\": \"Victor Plains\",\r\n \"suite\": \"Suite 879\",\r\n \"city\": \"Wisokyburgh\",\r\n \"zipcode\": \"90566-7771\",\r\n \"geo\": {\r\n \"lat\": \"-43.9509\",\r\n \"lng\": \"-34.4618\"\r\n }\r\n },\r\n \"phone\": \"010-692-6593 x09125\",\r\n \"website\": \"anastasia.net\",\r\n \"company\": {\r\n \"name\": \"Deckow-Crist\",\r\n \"catchPhrase\": \"Proactive didactic contingency\",\r\n \"bs\": \"synergize scalable supply-chains\"\r\n }\r\n },\r\n {\r\n \"id\": 3,\r\n \"name\": \"Clementine Bauch\",\r\n \"username\": \"Samantha\",\r\n \"email\": \"Nathan@yesenia.net\",\r\n \"address\": {\r\n \"street\": \"Douglas Extension\",\r\n \"suite\": \"Suite 847\",\r\n \"city\": \"McKenziehaven\",\r\n \"zipcode\": \"59590-4157\",\r\n \"geo\": {\r\n \"lat\": \"-68.6102\",\r\n \"lng\": \"-47.0653\"\r\n }\r\n },\r\n \"phone\": \"1-463-123-4447\",\r\n \"website\": \"ramiro.info\",\r\n \"company\": {\r\n \"name\": \"Romaguera-Jacobson\",\r\n \"catchPhrase\": \"Face to face bifurcated interface\",\r\n \"bs\": \"e-enable strategic applications\"\r\n }\r\n },\r\n {\r\n \"id\": 4,\r\n \"name\": \"Patricia Lebsack\",\r\n \"username\": \"Karianne\",\r\n \"email\": \"Julianne.OConner@kory.org\",\r\n \"address\": {\r\n \"street\": \"Hoeger Mall\",\r\n \"suite\": \"Apt. 692\",\r\n \"city\": \"South Elvis\",\r\n \"zipcode\": \"53919-4257\",\r\n \"geo\": {\r\n \"lat\": \"29.4572\",\r\n \"lng\": \"-164.2990\"\r\n }\r\n },\r\n \"phone\": \"493-170-9623 x156\",\r\n \"website\": \"kale.biz\",\r\n \"company\": {\r\n \"name\": \"Robel-Corkery\",\r\n \"catchPhrase\": \"Multi-tiered zero tolerance productivity\",\r\n \"bs\": \"transition cutting-edge web services\"\r\n }\r\n },\r\n {\r\n \"id\": 5,\r\n \"name\": \"Chelsey Dietrich\",\r\n \"username\": \"Kamren\",\r\n \"email\": \"Lucio_Hettinger@annie.ca\",\r\n \"address\": {\r\n \"street\": \"Skiles Walks\",\r\n \"suite\": \"Suite 351\",\r\n \"city\": \"Roscoeview\",\r\n \"zipcode\": \"33263\",\r\n \"geo\": {\r\n \"lat\": \"-31.8129\",\r\n \"lng\": \"62.5342\"\r\n }\r\n },\r\n \"phone\": \"(254)954-1289\",\r\n \"website\": \"demarco.info\",\r\n \"company\": {\r\n \"name\": \"Keebler LLC\",\r\n \"catchPhrase\": \"User-centric fault-tolerant solution\",\r\n \"bs\": \"revolutionize end-to-end systems\"\r\n }\r\n },\r\n {\r\n \"id\": 6,\r\n \"name\": \"Mrs. Dennis Schulist\",\r\n \"username\": \"Leopoldo_Corkery\",\r\n \"email\": \"Karley_Dach@jasper.info\",\r\n \"address\": {\r\n \"street\": \"Norberto Crossing\",\r\n \"suite\": \"Apt. 950\",\r\n \"city\": \"South Christy\",\r\n \"zipcode\": \"23505-1337\",\r\n \"geo\": {\r\n \"lat\": \"-71.4197\",\r\n \"lng\": \"71.7478\"\r\n }\r\n },\r\n \"phone\": \"1-477-935-8478 x6430\",\r\n \"website\": \"ola.org\",\r\n \"company\": {\r\n \"name\": \"Considine-Lockman\",\r\n \"catchPhrase\": \"Synchronised bottom-line interface\",\r\n \"bs\": \"e-enable innovative applications\"\r\n }\r\n },\r\n {\r\n \"id\": 7,\r\n \"name\": \"Kurtis Weissnat\",\r\n \"username\": \"Elwyn.Skiles\",\r\n \"email\": \"Telly.Hoeger@billy.biz\",\r\n \"address\": {\r\n \"street\": \"Rex Trail\",\r\n \"suite\": \"Suite 280\",\r\n \"city\": \"Howemouth\",\r\n \"zipcode\": \"58804-1099\",\r\n \"geo\": {\r\n \"lat\": \"24.8918\",\r\n \"lng\": \"21.8984\"\r\n }\r\n },\r\n \"phone\": \"210.067.6132\",\r\n \"website\": \"elvis.io\",\r\n \"company\": {\r\n \"name\": \"Johns Group\",\r\n \"catchPhrase\": \"Configurable multimedia task-force\",\r\n \"bs\": \"generate enterprise e-tailers\"\r\n }\r\n },\r\n {\r\n \"id\": 8,\r\n \"name\": \"Nicholas Runolfsdottir V\",\r\n \"username\": \"Maxime_Nienow\",\r\n \"email\": \"Sherwood@rosamond.me\",\r\n \"address\": {\r\n \"street\": \"Ellsworth Summit\",\r\n \"suite\": \"Suite 729\",\r\n \"city\": \"Aliyaview\",\r\n \"zipcode\": \"45169\",\r\n \"geo\": {\r\n \"lat\": \"-14.3990\",\r\n \"lng\": \"-120.7677\"\r\n }\r\n },\r\n \"phone\": \"586.493.6943 x140\",\r\n \"website\": \"jacynthe.com\",\r\n \"company\": {\r\n \"name\": \"Abernathy Group\",\r\n \"catchPhrase\": \"Implemented secondary concept\",\r\n \"bs\": \"e-enable extensible e-tailers\"\r\n }\r\n },\r\n {\r\n \"id\": 9,\r\n \"name\": \"Glenna Reichert\",\r\n \"username\": \"Delphine\",\r\n \"email\": \"Chaim_McDermott@dana.io\",\r\n \"address\": {\r\n \"street\": \"Dayna Park\",\r\n \"suite\": \"Suite 449\",\r\n \"city\": \"Bartholomebury\",\r\n \"zipcode\": \"76495-3109\",\r\n \"geo\": {\r\n \"lat\": \"24.6463\",\r\n \"lng\": \"-168.8889\"\r\n }\r\n },\r\n \"phone\": \"(775)976-6794 x41206\",\r\n \"website\": \"conrad.com\",\r\n \"company\": {\r\n \"name\": \"Yost and Sons\",\r\n \"catchPhrase\": \"Switchable contextually-based project\",\r\n \"bs\": \"aggregate real-time technologies\"\r\n }\r\n },\r\n {\r\n \"id\": 10,\r\n \"name\": \"Clementina DuBuque\",\r\n \"username\": \"Moriah.Stanton\",\r\n \"email\": \"Rey.Padberg@karina.biz\",\r\n \"address\": {\r\n \"street\": \"Kattie Turnpike\",\r\n \"suite\": \"Suite 198\",\r\n \"city\": \"Lebsackbury\",\r\n \"zipcode\": \"31428-2261\",\r\n \"geo\": {\r\n \"lat\": \"-38.2386\",\r\n \"lng\": \"57.2232\"\r\n }\r\n },\r\n \"phone\": \"024-648-3804\",\r\n \"website\": \"ambrose.net\",\r\n \"company\": {\r\n \"name\": \"Hoeger LLC\",\r\n \"catchPhrase\": \"Centralized empowering task-force\",\r\n \"bs\": \"target end-to-end models\"\r\n }\r\n }\r\n]".to_string()), - body_type: Some(BodyType::Json), - }))), - RequestKind::Single(Arc::new(RwLock::new(Request { - id: "any_other_id".to_string(), - name: "testing".to_string(), - auth_method: None, - uri: "https://jsonplaceholder.typicode.com/users".to_string(), - method: RequestMethod::Get, - parent: None, - headers: None, - body: Some("[\r\n {\r\n \"id\": 1,\r\n \"name\": \"Leanne Graham\",\r\n \"username\": \"Bret\",\r\n \"email\": \"Sincere@april.biz\",\r\n \"address\": {\r\n \"street\": \"Kulas Light\",\r\n \"suite\": \"Apt. 556\",\r\n \"city\": \"Gwenborough\",\r\n \"zipcode\": \"92998-3874\",\r\n \"geo\": {\r\n \"lat\": \"-37.3159\",\r\n \"lng\": \"81.1496\"\r\n }\r\n },\r\n \"phone\": \"1-770-736-8031 x56442\",\r\n \"website\": \"hildegard.org\",\r\n \"company\": {\r\n \"name\": \"Romaguera-Crona\",\r\n \"catchPhrase\": \"Multi-layered client-server neural-net\",\r\n \"bs\": \"harness real-time e-markets\"\r\n }\r\n },\r\n {\r\n \"id\": 2,\r\n \"name\": \"Ervin Howell\",\r\n \"username\": \"Antonette\",\r\n \"email\": \"Shanna@melissa.tv\",\r\n \"address\": {\r\n \"street\": \"Victor Plains\",\r\n \"suite\": \"Suite 879\",\r\n \"city\": \"Wisokyburgh\",\r\n \"zipcode\": \"90566-7771\",\r\n \"geo\": {\r\n \"lat\": \"-43.9509\",\r\n \"lng\": \"-34.4618\"\r\n }\r\n },\r\n \"phone\": \"010-692-6593 x09125\",\r\n \"website\": \"anastasia.net\",\r\n \"company\": {\r\n \"name\": \"Deckow-Crist\",\r\n \"catchPhrase\": \"Proactive didactic contingency\",\r\n \"bs\": \"synergize scalable supply-chains\"\r\n }\r\n },\r\n {\r\n \"id\": 3,\r\n \"name\": \"Clementine Bauch\",\r\n \"username\": \"Samantha\",\r\n \"email\": \"Nathan@yesenia.net\",\r\n \"address\": {\r\n \"street\": \"Douglas Extension\",\r\n \"suite\": \"Suite 847\",\r\n \"city\": \"McKenziehaven\",\r\n \"zipcode\": \"59590-4157\",\r\n \"geo\": {\r\n \"lat\": \"-68.6102\",\r\n \"lng\": \"-47.0653\"\r\n }\r\n },\r\n \"phone\": \"1-463-123-4447\",\r\n \"website\": \"ramiro.info\",\r\n \"company\": {\r\n \"name\": \"Romaguera-Jacobson\",\r\n \"catchPhrase\": \"Face to face bifurcated interface\",\r\n \"bs\": \"e-enable strategic applications\"\r\n }\r\n },\r\n {\r\n \"id\": 4,\r\n \"name\": \"Patricia Lebsack\",\r\n \"username\": \"Karianne\",\r\n \"email\": \"Julianne.OConner@kory.org\",\r\n \"address\": {\r\n \"street\": \"Hoeger Mall\",\r\n \"suite\": \"Apt. 692\",\r\n \"city\": \"South Elvis\",\r\n \"zipcode\": \"53919-4257\",\r\n \"geo\": {\r\n \"lat\": \"29.4572\",\r\n \"lng\": \"-164.2990\"\r\n }\r\n },\r\n \"phone\": \"493-170-9623 x156\",\r\n \"website\": \"kale.biz\",\r\n \"company\": {\r\n \"name\": \"Robel-Corkery\",\r\n \"catchPhrase\": \"Multi-tiered zero tolerance productivity\",\r\n \"bs\": \"transition cutting-edge web services\"\r\n }\r\n },\r\n {\r\n \"id\": 5,\r\n \"name\": \"Chelsey Dietrich\",\r\n \"username\": \"Kamren\",\r\n \"email\": \"Lucio_Hettinger@annie.ca\",\r\n \"address\": {\r\n \"street\": \"Skiles Walks\",\r\n \"suite\": \"Suite 351\",\r\n \"city\": \"Roscoeview\",\r\n \"zipcode\": \"33263\",\r\n \"geo\": {\r\n \"lat\": \"-31.8129\",\r\n \"lng\": \"62.5342\"\r\n }\r\n },\r\n \"phone\": \"(254)954-1289\",\r\n \"website\": \"demarco.info\",\r\n \"company\": {\r\n \"name\": \"Keebler LLC\",\r\n \"catchPhrase\": \"User-centric fault-tolerant solution\",\r\n \"bs\": \"revolutionize end-to-end systems\"\r\n }\r\n },\r\n {\r\n \"id\": 6,\r\n \"name\": \"Mrs. Dennis Schulist\",\r\n \"username\": \"Leopoldo_Corkery\",\r\n \"email\": \"Karley_Dach@jasper.info\",\r\n \"address\": {\r\n \"street\": \"Norberto Crossing\",\r\n \"suite\": \"Apt. 950\",\r\n \"city\": \"South Christy\",\r\n \"zipcode\": \"23505-1337\",\r\n \"geo\": {\r\n \"lat\": \"-71.4197\",\r\n \"lng\": \"71.7478\"\r\n }\r\n },\r\n \"phone\": \"1-477-935-8478 x6430\",\r\n \"website\": \"ola.org\",\r\n \"company\": {\r\n \"name\": \"Considine-Lockman\",\r\n \"catchPhrase\": \"Synchronised bottom-line interface\",\r\n \"bs\": \"e-enable innovative applications\"\r\n }\r\n },\r\n {\r\n \"id\": 7,\r\n \"name\": \"Kurtis Weissnat\",\r\n \"username\": \"Elwyn.Skiles\",\r\n \"email\": \"Telly.Hoeger@billy.biz\",\r\n \"address\": {\r\n \"street\": \"Rex Trail\",\r\n \"suite\": \"Suite 280\",\r\n \"city\": \"Howemouth\",\r\n \"zipcode\": \"58804-1099\",\r\n \"geo\": {\r\n \"lat\": \"24.8918\",\r\n \"lng\": \"21.8984\"\r\n }\r\n },\r\n \"phone\": \"210.067.6132\",\r\n \"website\": \"elvis.io\",\r\n \"company\": {\r\n \"name\": \"Johns Group\",\r\n \"catchPhrase\": \"Configurable multimedia task-force\",\r\n \"bs\": \"generate enterprise e-tailers\"\r\n }\r\n },\r\n {\r\n \"id\": 8,\r\n \"name\": \"Nicholas Runolfsdottir V\",\r\n \"username\": \"Maxime_Nienow\",\r\n \"email\": \"Sherwood@rosamond.me\",\r\n \"address\": {\r\n \"street\": \"Ellsworth Summit\",\r\n \"suite\": \"Suite 729\",\r\n \"city\": \"Aliyaview\",\r\n \"zipcode\": \"45169\",\r\n \"geo\": {\r\n \"lat\": \"-14.3990\",\r\n \"lng\": \"-120.7677\"\r\n }\r\n },\r\n \"phone\": \"586.493.6943 x140\",\r\n \"website\": \"jacynthe.com\",\r\n \"company\": {\r\n \"name\": \"Abernathy Group\",\r\n \"catchPhrase\": \"Implemented secondary concept\",\r\n \"bs\": \"e-enable extensible e-tailers\"\r\n }\r\n },\r\n {\r\n \"id\": 9,\r\n \"name\": \"Glenna Reichert\",\r\n \"username\": \"Delphine\",\r\n \"email\": \"Chaim_McDermott@dana.io\",\r\n \"address\": {\r\n \"street\": \"Dayna Park\",\r\n \"suite\": \"Suite 449\",\r\n \"city\": \"Bartholomebury\",\r\n \"zipcode\": \"76495-3109\",\r\n \"geo\": {\r\n \"lat\": \"24.6463\",\r\n \"lng\": \"-168.8889\"\r\n }\r\n },\r\n \"phone\": \"(775)976-6794 x41206\",\r\n \"website\": \"conrad.com\",\r\n \"company\": {\r\n \"name\": \"Yost and Sons\",\r\n \"catchPhrase\": \"Switchable contextually-based project\",\r\n \"bs\": \"aggregate real-time technologies\"\r\n }\r\n },\r\n {\r\n \"id\": 10,\r\n \"name\": \"Clementina DuBuque\",\r\n \"username\": \"Moriah.Stanton\",\r\n \"email\": \"Rey.Padberg@karina.biz\",\r\n \"address\": {\r\n \"street\": \"Kattie Turnpike\",\r\n \"suite\": \"Suite 198\",\r\n \"city\": \"Lebsackbury\",\r\n \"zipcode\": \"31428-2261\",\r\n \"geo\": {\r\n \"lat\": \"-38.2386\",\r\n \"lng\": \"57.2232\"\r\n }\r\n },\r\n \"phone\": \"024-648-3804\",\r\n \"website\": \"ambrose.net\",\r\n \"company\": {\r\n \"name\": \"Hoeger LLC\",\r\n \"catchPhrase\": \"Centralized empowering task-force\",\r\n \"bs\": \"target end-to-end models\"\r\n }\r\n }\r\n]".to_string()), - body_type: Some(BodyType::Json), - }))), - ]))) - } -} - -fn feed_keys(widget: &mut CollectionViewer, key_codes: Vec) { - key_codes.into_iter().for_each(|code| { - widget - .handle_key_event(KeyEvent { - code, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - state: KeyEventState::NONE, - }) - .unwrap(); - }); -} - -#[divan::bench] -fn handling_key_events() { - let colors = hac_colors::Colors::default(); - let collection = create_sample_collection(); - let size = Rect::new(0, 0, 80, 24); - let config = hac_config::load_config(); - let mut store = CollectionStore::default(); - store.set_state(collection); - let mut api_explorer = - CollectionViewer::new(size, Rc::new(RefCell::new(store)), &colors, &config, false); - let mut terminal = Terminal::new(TestBackend::new(size.width, size.height)).unwrap(); - let mut frame = terminal.get_frame(); - - api_explorer.draw(&mut frame, size).unwrap(); - feed_keys( - &mut api_explorer, - vec![ - KeyCode::Char('j'), - KeyCode::Char('j'), - KeyCode::Char('j'), - KeyCode::Char('j'), - KeyCode::Char('j'), - KeyCode::Char('j'), - KeyCode::Char('j'), - ], - ); - api_explorer.draw(&mut frame, size).unwrap(); -} - -#[divan::bench] -fn creating_with_highlight() { - let colors = hac_colors::Colors::default(); - let collection = create_sample_collection(); - let size = Rect::new(0, 0, 80, 24); - let config = hac_config::load_config(); - let mut store = CollectionStore::default(); - store.set_state(collection); - let mut api_explorer = - CollectionViewer::new(size, Rc::new(RefCell::new(store)), &colors, &config, false); - let mut terminal = Terminal::new(TestBackend::new(size.width, size.height)).unwrap(); - let _frame = terminal.get_frame(); - - // we are simulating changing the active request 12 times, which will rebuild the highlight - // tree. - feed_keys( - &mut api_explorer, - vec![ - KeyCode::Tab, - KeyCode::Tab, - KeyCode::Tab, - KeyCode::Char('j'), - KeyCode::Enter, - KeyCode::Char('k'), - KeyCode::Enter, - KeyCode::Char('j'), - KeyCode::Enter, - KeyCode::Char('k'), - KeyCode::Enter, - KeyCode::Char('j'), - KeyCode::Enter, - KeyCode::Char('k'), - KeyCode::Enter, - KeyCode::Char('j'), - KeyCode::Enter, - KeyCode::Char('k'), - KeyCode::Enter, - KeyCode::Char('j'), - KeyCode::Enter, - KeyCode::Char('k'), - KeyCode::Enter, - KeyCode::Char('j'), - KeyCode::Enter, - KeyCode::Char('k'), - KeyCode::Enter, - ], - ); -} - -lazy_static! { - static ref BODY: &'static str = "[\r\n {\r\n \"id\": 1,\r\n \"name\": \"Leanne Graham\",\r\n \"username\": \"Bret\",\r\n \"email\": \"Sincere@april.biz\",\r\n \"address\": {\r\n \"street\": \"Kulas Light\",\r\n \"suite\": \"Apt. 556\",\r\n \"city\": \"Gwenborough\",\r\n \"zipcode\": \"92998-3874\",\r\n \"geo\": {\r\n \"lat\": \"-37.3159\",\r\n \"lng\": \"81.1496\"\r\n }\r\n },\r\n \"phone\": \"1-770-736-8031 x56442\",\r\n \"website\": \"hildegard.org\",\r\n \"company\": {\r\n \"name\": \"Romaguera-Crona\",\r\n \"catchPhrase\": \"Multi-layered client-server neural-net\",\r\n \"bs\": \"harness real-time e-markets\"\r\n }\r\n },\r\n {\r\n \"id\": 2,\r\n \"name\": \"Ervin Howell\",\r\n \"username\": \"Antonette\",\r\n \"email\": \"Shanna@melissa.tv\",\r\n \"address\": {\r\n \"street\": \"Victor Plains\",\r\n \"suite\": \"Suite 879\",\r\n \"city\": \"Wisokyburgh\",\r\n \"zipcode\": \"90566-7771\",\r\n \"geo\": {\r\n \"lat\": \"-43.9509\",\r\n \"lng\": \"-34.4618\"\r\n }\r\n },\r\n \"phone\": \"010-692-6593 x09125\",\r\n \"website\": \"anastasia.net\",\r\n \"company\": {\r\n \"name\": \"Deckow-Crist\",\r\n \"catchPhrase\": \"Proactive didactic contingency\",\r\n \"bs\": \"synergize scalable supply-chains\"\r\n }\r\n },\r\n {\r\n \"id\": 3,\r\n \"name\": \"Clementine Bauch\",\r\n \"username\": \"Samantha\",\r\n \"email\": \"Nathan@yesenia.net\",\r\n \"address\": {\r\n \"street\": \"Douglas Extension\",\r\n \"suite\": \"Suite 847\",\r\n \"city\": \"McKenziehaven\",\r\n \"zipcode\": \"59590-4157\",\r\n \"geo\": {\r\n \"lat\": \"-68.6102\",\r\n \"lng\": \"-47.0653\"\r\n }\r\n },\r\n \"phone\": \"1-463-123-4447\",\r\n \"website\": \"ramiro.info\",\r\n \"company\": {\r\n \"name\": \"Romaguera-Jacobson\",\r\n \"catchPhrase\": \"Face to face bifurcated interface\",\r\n \"bs\": \"e-enable strategic applications\"\r\n }\r\n },\r\n {\r\n \"id\": 4,\r\n \"name\": \"Patricia Lebsack\",\r\n \"username\": \"Karianne\",\r\n \"email\": \"Julianne.OConner@kory.org\",\r\n \"address\": {\r\n \"street\": \"Hoeger Mall\",\r\n \"suite\": \"Apt. 692\",\r\n \"city\": \"South Elvis\",\r\n \"zipcode\": \"53919-4257\",\r\n \"geo\": {\r\n \"lat\": \"29.4572\",\r\n \"lng\": \"-164.2990\"\r\n }\r\n },\r\n \"phone\": \"493-170-9623 x156\",\r\n \"website\": \"kale.biz\",\r\n \"company\": {\r\n \"name\": \"Robel-Corkery\",\r\n \"catchPhrase\": \"Multi-tiered zero tolerance productivity\",\r\n \"bs\": \"transition cutting-edge web services\"\r\n }\r\n },\r\n {\r\n \"id\": 5,\r\n \"name\": \"Chelsey Dietrich\",\r\n \"username\": \"Kamren\",\r\n \"email\": \"Lucio_Hettinger@annie.ca\",\r\n \"address\": {\r\n \"street\": \"Skiles Walks\",\r\n \"suite\": \"Suite 351\",\r\n \"city\": \"Roscoeview\",\r\n \"zipcode\": \"33263\",\r\n \"geo\": {\r\n \"lat\": \"-31.8129\",\r\n \"lng\": \"62.5342\"\r\n }\r\n },\r\n \"phone\": \"(254)954-1289\",\r\n \"website\": \"demarco.info\",\r\n \"company\": {\r\n \"name\": \"Keebler LLC\",\r\n \"catchPhrase\": \"User-centric fault-tolerant solution\",\r\n \"bs\": \"revolutionize end-to-end systems\"\r\n }\r\n },\r\n {\r\n \"id\": 6,\r\n \"name\": \"Mrs. Dennis Schulist\",\r\n \"username\": \"Leopoldo_Corkery\",\r\n \"email\": \"Karley_Dach@jasper.info\",\r\n \"address\": {\r\n \"street\": \"Norberto Crossing\",\r\n \"suite\": \"Apt. 950\",\r\n \"city\": \"South Christy\",\r\n \"zipcode\": \"23505-1337\",\r\n \"geo\": {\r\n \"lat\": \"-71.4197\",\r\n \"lng\": \"71.7478\"\r\n }\r\n },\r\n \"phone\": \"1-477-935-8478 x6430\",\r\n \"website\": \"ola.org\",\r\n \"company\": {\r\n \"name\": \"Considine-Lockman\",\r\n \"catchPhrase\": \"Synchronised bottom-line interface\",\r\n \"bs\": \"e-enable innovative applications\"\r\n }\r\n },\r\n {\r\n \"id\": 7,\r\n \"name\": \"Kurtis Weissnat\",\r\n \"username\": \"Elwyn.Skiles\",\r\n \"email\": \"Telly.Hoeger@billy.biz\",\r\n \"address\": {\r\n \"street\": \"Rex Trail\",\r\n \"suite\": \"Suite 280\",\r\n \"city\": \"Howemouth\",\r\n \"zipcode\": \"58804-1099\",\r\n \"geo\": {\r\n \"lat\": \"24.8918\",\r\n \"lng\": \"21.8984\"\r\n }\r\n },\r\n \"phone\": \"210.067.6132\",\r\n \"website\": \"elvis.io\",\r\n \"company\": {\r\n \"name\": \"Johns Group\",\r\n \"catchPhrase\": \"Configurable multimedia task-force\",\r\n \"bs\": \"generate enterprise e-tailers\"\r\n }\r\n },\r\n {\r\n \"id\": 8,\r\n \"name\": \"Nicholas Runolfsdottir V\",\r\n \"username\": \"Maxime_Nienow\",\r\n \"email\": \"Sherwood@rosamond.me\",\r\n \"address\": {\r\n \"street\": \"Ellsworth Summit\",\r\n \"suite\": \"Suite 729\",\r\n \"city\": \"Aliyaview\",\r\n \"zipcode\": \"45169\",\r\n \"geo\": {\r\n \"lat\": \"-14.3990\",\r\n \"lng\": \"-120.7677\"\r\n }\r\n },\r\n \"phone\": \"586.493.6943 x140\",\r\n \"website\": \"jacynthe.com\",\r\n \"company\": {\r\n \"name\": \"Abernathy Group\",\r\n \"catchPhrase\": \"Implemented secondary concept\",\r\n \"bs\": \"e-enable extensible e-tailers\"\r\n }\r\n },\r\n {\r\n \"id\": 9,\r\n \"name\": \"Glenna Reichert\",\r\n \"username\": \"Delphine\",\r\n \"email\": \"Chaim_McDermott@dana.io\",\r\n \"address\": {\r\n \"street\": \"Dayna Park\",\r\n \"suite\": \"Suite 449\",\r\n \"city\": \"Bartholomebury\",\r\n \"zipcode\": \"76495-3109\",\r\n \"geo\": {\r\n \"lat\": \"24.6463\",\r\n \"lng\": \"-168.8889\"\r\n }\r\n },\r\n \"phone\": \"(775)976-6794 x41206\",\r\n \"website\": \"conrad.com\",\r\n \"company\": {\r\n \"name\": \"Yost and Sons\",\r\n \"catchPhrase\": \"Switchable contextually-based project\",\r\n \"bs\": \"aggregate real-time technologies\"\r\n }\r\n },\r\n {\r\n \"id\": 10,\r\n \"name\": \"Clementina DuBuque\",\r\n \"username\": \"Moriah.Stanton\",\r\n \"email\": \"Rey.Padberg@karina.biz\",\r\n \"address\": {\r\n \"street\": \"Kattie Turnpike\",\r\n \"suite\": \"Suite 198\",\r\n \"city\": \"Lebsackbury\",\r\n \"zipcode\": \"31428-2261\",\r\n \"geo\": {\r\n \"lat\": \"-38.2386\",\r\n \"lng\": \"57.2232\"\r\n }\r\n },\r\n \"phone\": \"024-648-3804\",\r\n \"website\": \"ambrose.net\",\r\n \"company\": {\r\n \"name\": \"Hoeger LLC\",\r\n \"catchPhrase\": \"Centralized empowering task-force\",\r\n \"bs\": \"target end-to-end models\"\r\n }\r\n }\r\n]"; - static ref TREE: Option = Highlighter::default().parse(&BODY); -} - -#[divan::bench] -fn benchmarking_building_content() { - let colors = hac_colors::Colors::default(); - build_syntax_highlighted_lines(&BODY, TREE.as_ref(), &colors); -} diff --git a/hac-client/src/app.rs b/hac-client/src/app.rs index a9dad9a..05db92b 100755 --- a/hac-client/src/app.rs +++ b/hac-client/src/app.rs @@ -1,39 +1,106 @@ -use hac_core::{collection::Collection, command::Command}; +use std::io::Stdout; +use std::sync::mpsc::{Receiver, Sender}; +use std::time::{Duration, Instant}; + +pub trait A { + fn asda() -> T; +} + +use hac_core::command::Command; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; use crate::event_pool::{Event, EventPool}; -use crate::pages::{Eventful, Renderable}; -use crate::screen_manager::ScreenManager; +use crate::pages::collection_list::CollectionList; +use crate::renderable::{Eventful, Renderable}; +use crate::router::Router; +use crate::{HacColors, HacConfig}; -use std::io::Stdout; +static FRAME_RATE: f64 = 60f64; +static TICK_RATE: f64 = 30f64; + +#[derive(Debug)] +pub struct InvalidRouteNumber(u8); + +impl std::error::Error for InvalidRouteNumber {} + +impl std::fmt::Display for InvalidRouteNumber { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "number {} is not a valid route number", self.0) + } +} + +#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy, Default)] +pub enum Routes { + #[default] + ListCollections, + CreateCollection, + EditCollection, + DeleteCollection, + CollectionViewer, + CreateRequest, + EditRequest, + DeleteItem, +} -use ratatui::{backend::CrosstermBackend, Terminal}; -use tokio::sync::mpsc; +impl TryFrom for Routes { + type Error = InvalidRouteNumber; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Routes::ListCollections), + 1 => Ok(Routes::CreateCollection), + 2 => Ok(Routes::EditCollection), + 3 => Ok(Routes::DeleteCollection), + 4 => Ok(Routes::CollectionViewer), + 5 => Ok(Routes::CreateRequest), + 6 => Ok(Routes::EditRequest), + 7 => Ok(Routes::DeleteItem), + _ => Err(InvalidRouteNumber(value)), + } + } +} -pub struct App<'app> { +impl From for u8 { + fn from(val: Routes) -> Self { + match val { + Routes::ListCollections => 0, + Routes::CreateCollection => 1, + Routes::EditCollection => 2, + Routes::DeleteCollection => 3, + Routes::CollectionViewer => 4, + Routes::CreateRequest => 5, + Routes::EditRequest => 6, + Routes::DeleteItem => 7, + } + } +} + +pub struct App { event_pool: EventPool, terminal: Terminal>, should_quit: bool, - screen_manager: ScreenManager<'app>, + command_receiver: Receiver, + command_sender: Sender, + router: Router, } -impl<'app> App<'app> { - pub fn new( - colors: &'app hac_colors::Colors, - collections: Vec, - config: &'app hac_config::Config, - dry_run: bool, - ) -> anyhow::Result { +impl App { + pub fn new(config: HacConfig, colors: HacColors) -> anyhow::Result { let terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?; + let (command_sender, command_receiver) = std::sync::mpsc::channel(); + let size = terminal.size()?; + + let mut router = Router::new(command_sender.clone(), colors.clone()); + let collection_list = CollectionList::new(size, config.clone(), colors.clone()); + router.add_route(Routes::ListCollections.into(), Box::new(collection_list)); + Ok(Self { - screen_manager: ScreenManager::new( - terminal.size()?, - colors, - collections, - config, - dry_run, - )?, - event_pool: EventPool::new(60f64, 30f64), + event_pool: EventPool::default(), should_quit: false, + router, + command_receiver, + command_sender, terminal, }) } @@ -41,48 +108,52 @@ impl<'app> App<'app> { /// this is the main method which starts the event loop task, listen for events and commands /// to pass them down the chain, and render the terminal screen pub async fn run(&mut self) -> anyhow::Result<()> { - let (command_tx, mut command_rx) = mpsc::unbounded_channel(); self.event_pool.start(); - startup()?; - self.screen_manager - .register_command_handler(command_tx.clone())?; + let render_delay = Duration::from_secs_f64(1.0 / FRAME_RATE); + let tick_delay = Duration::from_secs_f64(1.0 / TICK_RATE); + let frame_delta = Instant::now(); + let mut tick_delta = Instant::now(); loop { + let frame_start = Instant::now(); + { - while let Ok(command) = command_rx.try_recv() { + while let Ok(command) = self.command_receiver.try_recv() { match command { Command::Quit => self.should_quit = true, - _ => self.screen_manager.handle_command(command), + _ => self.router.handle_command(command)?, } } } - if let Some(event) = self.event_pool.next().await { + if let Some(event) = self.event_pool.next_event() { match event { - Event::Tick => self.screen_manager.handle_tick()?, - Event::Resize(new_size) => self.screen_manager.resize(new_size), - Event::Render => { - self.terminal.draw(|f| { - let result = self.screen_manager.draw(f, f.size()); - if let Err(e) = result { - command_tx - .send(Command::Error(format!("Failed to draw: {:?}", e))) - .expect("failed to send command through channel"); - } - })?; - } - event => { - if let Some(command) = - self.screen_manager.handle_event(Some(event.clone()))? - { - command_tx + Event::Resize(new_size) => self.router.resize(new_size), + Event::Key(_) => { + if let Some(command) = self.router.handle_event(Some(event.clone()))? { + self.command_sender .send(command) .expect("failed to send command through channel") } } - }; + } + } + + if frame_start.duration_since(tick_delta) >= tick_delay { + self.router.tick()?; + tick_delta = frame_start; + } + + if frame_start.duration_since(frame_delta) >= render_delay { + self.terminal.draw(|f| { + if let Err(e) = self.router.draw(f, f.size()) { + self.command_sender + .send(Command::Error(format!("Failed to draw: {:?}", e))) + .expect("failed to send command through channel"); + } + })?; } if self.should_quit { diff --git a/hac-client/src/ascii.rs b/hac-client/src/ascii.rs index b65a547..c89b24d 100755 --- a/hac-client/src/ascii.rs +++ b/hac-client/src/ascii.rs @@ -1,149 +1,38 @@ -pub static BIG_ERROR_ARTS: &[&[&str]] = &[ - &[ - r#" ,adPPYba, 8b,dPPYba, 8b,dPPYba, ,adPPYba, 8b,dPPYba, "#, - r#"a8P_____88 88P' "Y8 88P' "Y8 a8" "8a 88P' "Y8 "#, - r#"8PP""""""" 88 88 8b d8 88 "#, - r#""8b, ,aa 88 88 "8a, ,a8" 88 "#, - r#" `"Ybbd8"' 88 88 `"YbbdP"' 88 "#, - ], - &[ - r#".d88b. 888d888888d888 .d88b. 888d888 "#, - r#"d8P Y8b888P" 888P" d88""88b888P" "#, - r#"88888888888 888 888 888888 "#, - r#"Y8b. 888 888 Y88..88P888 "#, - r#" "Y8888 888 888 "Y88P" 888 "#, - ], - &[ - r#" __ ,-. __ ,-. ,---. __ ,-. "#, - r#" ,' ,'/ /|,' ,'/ /| ' ,'\ ,' ,'/ /| "#, - r#" ,---. ' | |' |' | |' | / / |' | |' | "#, - r#" / \ | | ,'| | ,'. ; ,. :| | ,' "#, - r#" / / |' : / ' : / ' | |: :' : / "#, - r#". ' / || | ' | | ' ' | .; :| | ' "#, - r#"' ; /|; : | ; : | | : |; : | "#, - r#"' | / || , ; | , ; \ \ / | , ; "#, - r#"| : | ---' ---' `----' ---' "#, - r#" \ \ / "#, - r#" `----' "#, - ], - &[ - r#" /$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$ "#, - r#" /$$__ $$ /$$__ $$ /$$__ $$ /$$__ $$ /$$__ $$"#, - r#"| $$$$$$$$| $$ \__/| $$ \__/| $$ \ $$| $$ \__/"#, - r#"| $$_____/| $$ | $$ | $$ | $$| $$ "#, - r#"| $$$$$$$| $$ | $$ | $$$$$$/| $$ "#, - r#" \_______/|__/ |__/ \______/ |__/ "#, - ], - &[ - r#" .u . .u . u. .u . "#, - r#" .u .d88B :@8c .d88B :@8c ...ue888b .d88B :@8c "#, - r#" ud8888. ="8888f8888r ="8888f8888r 888R Y888r ="8888f8888r "#, - r#":888'8888. 4888>'88" 4888>'88" 888R I888> 4888>'88" "#, - r#"d888 '88%" 4888> ' 4888> ' 888R I888> 4888> ' "#, - r#"8888.+" 4888> 4888> 888R I888> 4888> "#, - r#"8888L .d888L .+ .d888L .+ u8888cJ888 .d888L .+ "#, - r#"'8888c. .+ ^"8888*" ^"8888*" "*888*P" ^"8888*" "#, - r#" "88888% "Y" "Y" 'Y" "Y" "#, - r#" "YP' "#, - ], +pub static BIG_ERROR_ART: &[&str] = &[ + r#" __ ,-. __ ,-. ,---. __ ,-. "#, + r#" ,' ,'/ /|,' ,'/ /| ' ,'\ ,' ,'/ /| "#, + r#" ,---. ' | |' |' | |' | / / |' | |' | "#, + r#" / \ | | ,'| | ,'. ; ,. :| | ,' "#, + r#" / / |' : / ' : / ' | |: :' : / "#, + r#". ' / || | ' | | ' ' | .; :| | ' "#, + r#"' ; /|; : | ; : | | : |; : | "#, + r#"' | / || , ; | , ; \ \ / | , ; "#, + r#"| : | ---' ---' `----' ---' "#, + r#" \ \ / "#, + r#" `----' "#, ]; -pub static SMALL_ERROR_ARTS: &[&[&str]] = &[ - &[ - r#" ___ _ __ _ __ ___ _ __ "#, - r#" / _ \ '__| '__/ _ \| '__|"#, - r#" | __/ | | | | (_) | | "#, - r#" \___|_| |_| \___/|_| "#, - ], - &[ - r#" __ _ __ _ __ ___ _ __ "#, - r#" /'__`\/\`'__\/\`'__\/ __`\/\`'__\"#, - r#"/\ __/\ \ \/ \ \ \//\ \L\ \ \ \/ "#, - r#"\ \____\\ \_\ \ \_\\ \____/\ \_\ "#, - r#" \/____/ \/_/ \/_/ \/___/ \/_/ "#, - ], - &[ - r#" dBBBP dBBBBBb dBBBBBb dBBBBP dBBBBBb"#, - r#" dBP dBP dBP.BP dBP"#, - r#" dBBP dBBBBK dBBBBK dBP.BP dBBBBK "#, - r#" dBP dBP BB dBP BB dBP.BP dBP BB "#, - r#" dBBBBP dBP dB' dBP dB' dBBBBP dBP dB' "#, - ], - &[ - r#".d88b 8d8b 8d8b .d8b. 8d8b "#, - r#"8.dP' 8P 8P 8' .8 8P "#, - r#"`Y88P 8 8 `Y8P' 8 "#, - ], - &[ - r#" ____ ____ ____ ___ ____ "#, - r#" / _ )/ ___) ___) _ \ / ___)"#, - r#"( (/ /| | | | | |_| | | "#, - r#" \____)_| |_| \___/|_| "#, - ], +pub static SMALL_ERROR_ART: &[&str] = &[ + r#" __ _ __ _ __ ___ _ __ "#, + r#" /'__`\/\`'__\/\`'__\/ __`\/\`'__\"#, + r#"/\ __/\ \ \/ \ \ \//\ \L\ \ \ \/ "#, + r#"\ \____\\ \_\ \ \_\\ \____/\ \_\ "#, + r#" \/____/ \/_/ \/_/ \/___/ \/_/ "#, ]; -pub static LOGO_ASCII: &[&[&str]] = &[ - &[ - r#" ,--, "#, - r#" ,--.'| ,---, ,----.. "#, - r#" ,--, | : ' .' \ / / \ "#, - r#",---.'| : ' / ; '. | : : "#, - r#"| | : _' |: : \ . | ;. / "#, - r#": : |.' |: | /\ \ . ; /--` "#, - r#"| ' ' ; :| : ' ;. : ; | ; "#, - r#"' | .'. || | ;/ \ \| : | "#, - r#"| | : | '' : | \ \ ,'. | '___ "#, - r#"' : | : ;| | ' '--' ' ; : .'| "#, - r#"| | ' ,/ | : : ' | '/ : "#, - r#"; : ;--' | | ,' | : / "#, - r#"| ,/ `--'' \ \ .' "#, - r#"'---' `---` "#, - ], - &[ - r#" ____ ____ ____ _____ "#, - r#"| | | | ____|\ \ ___|\ \ "#, - r#"| | | | / /\ \ / /\ \ "#, - r#"| |_| || | | || | | |"#, - r#"| .-. || |__| || | |____|"#, - r#"| | | || .--. || | ____ "#, - r#"| | | || | | || | | |"#, - r#"|____| |____||____| |____||\ ___\/ /|"#, - r#"| | | || | | || | /____/ |"#, - r#"|____| |____||____| |____| \|___| | /"#, - r#" \( )/ \( )/ \( |____|/ "#, - r#" ' ' ' ' ' )/ "#, - r#" ' "#, - ], - &[ - r#" .uef^" "#, - r#":d88E "#, - r#"`888E u . "#, - r#" 888E .z8k us888u. .udR88N "#, - r#" 888E~?888L .@88 "8888" <888'888k "#, - r#" 888E 888E 9888 9888 9888 'Y" "#, - r#" 888E 888E 9888 9888 9888 "#, - r#" 888E 888E 9888 9888 9888 "#, - r#" 888E 888E 9888 9888 ?8888u../ "#, - r#"m888N= 888> "888*""888" "8888P' "#, - r#" `Y" 888 ^Y" ^Y' "P' "#, - r#" J88" "#, - r#" @% "#, - r#" :" "#, - ], -]; - -pub static UNDER_CONSTRUCTION: &[&str] = &[ - r#"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"#, - r#"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣿⣿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"#, - r#"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"#, - r#"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"#, - r#"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣤⣄⣀⣀⣠⣤⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"#, - r#"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"#, - r#"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠻⠿⢿⣿⣿⡿⠿⠟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"#, - r#"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"#, - r#"⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⣿⣶⣤⣤⣤⣤⣤⣤⣶⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀"#, - r#"⠀⠀⠀⠀⣀⣠⣤⡖⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⢶⣤⣄⣀⠀⠀⠀⠀"#, - r#"⠀⠀⠀⠉⠙⠻⢿⣿⡀⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⢀⣿⡿⠟⠋⠉⠀⠀⠀"#, - r#"⠀⠀⠀⠀⠀⠀⠀⠀⠉⠁⠢⠤⣤⣀⣈⣁⣀⣤⠤⠔⠈⠉⠀⠀⠀⠀⠀⠀⠀⠀"#, - r#"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀"#, +pub static LOGO_ASCII: &[&str] = &[ + r#" ,--, "#, + r#" ,--.'| ,---, ,----.. "#, + r#" ,--, | : ' .' \ / / \ "#, + r#",---.'| : ' / ; '. | : : "#, + r#"| | : _' |: : \ . | ;. / "#, + r#": : |.' |: | /\ \ . ; /--` "#, + r#"| ' ' ; :| : ' ;. : ; | ; "#, + r#"' | .'. || | ;/ \ \| : | "#, + r#"| | : | '' : | \ \ ,'. | '___ "#, + r#"' : | : ;| | ' '--' ' ; : .'| "#, + r#"| | ' ,/ | : : ' | '/ : "#, + r#"; : ;--' | | ,' | : / "#, + r#"| ,/ `--'' \ \ .' "#, + r#"'---' `---` "#, ]; diff --git a/hac-client/src/components.rs b/hac-client/src/components.rs deleted file mode 100644 index 9b94ab1..0000000 --- a/hac-client/src/components.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod component_styles; -pub mod key_value_pair; -pub mod list_item; diff --git a/hac-client/src/components/blending_list.rs b/hac-client/src/components/blending_list.rs new file mode 100644 index 0000000..0f40e3a --- /dev/null +++ b/hac-client/src/components/blending_list.rs @@ -0,0 +1,89 @@ +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::{Block, Padding, Paragraph}; +use ratatui::Frame; + +use crate::utils::alpha_blend_multiply; +use crate::HacColors; + +#[derive(Debug)] +pub struct BlendingList { + pub selected: usize, + scroll: usize, + page_size: usize, + total: usize, + colors: HacColors, + padding: u16, +} + +impl BlendingList { + pub fn new(selected: usize, total: usize, page_size: usize, padding: u16, colors: HacColors) -> Self { + Self { + selected, + total, + page_size, + scroll: 0, + colors, + padding, + } + } + + pub fn reset(&mut self) { + self.selected = 0; + self.scroll = 0; + } + + pub fn select_up(&mut self) { + self.selected = self.selected.saturating_sub(1); + if self.selected.saturating_sub(self.scroll) == 0 { + self.scroll = self.scroll - (self.scroll - self.selected); + } + } + + pub fn select_down(&mut self) { + self.selected = usize::min(self.selected + 1, self.total - 1); + if self.selected - self.scroll >= self.page_size.saturating_sub(1) { + let diff = usize::abs_diff(self.selected - self.scroll, self.page_size.saturating_sub(1)); + self.scroll += diff; + } + } + + pub fn draw_with<'a, T: 'a, E: Fn(&'a T) -> O, O: AsRef>( + &mut self, + frame: &mut Frame, + items: impl Iterator, + extractor: E, + area: Rect, + ) { + let areas = Layout::vertical((0..self.page_size).map(|_| Constraint::Length(1 + self.padding))).split(area); + let blend_max = 0.2; + let blend_step = 1.0 / self.page_size as f32; + + for (i, item) in items.skip(self.scroll).take(self.page_size).enumerate() { + let diff_from_selected = usize::abs_diff(i, self.selected - self.scroll); + let blend_step = diff_from_selected as f32 * blend_step; + let name = extractor(item); + + let mut color = alpha_blend_multiply( + self.colors.normal.white, + self.colors.normal.black, + f32::max(1.0 - blend_step, blend_max), + ); + + if self.selected - self.scroll == i { + color = self.colors.normal.red; + } + + let mut block = Block::new(); + if self.padding > 0 { + block = block.padding(Padding::top(self.padding)); + } + + frame.render_widget( + Paragraph::new(Line::from(name.as_ref().fg(color)).centered()).block(block), + areas[i], + ); + } + } +} diff --git a/hac-client/src/components/component_styles.rs b/hac-client/src/components/component_styles.rs deleted file mode 100644 index e8df6f4..0000000 --- a/hac-client/src/components/component_styles.rs +++ /dev/null @@ -1,31 +0,0 @@ -#[derive(Debug, Clone, Copy)] -pub enum ComponentBorder { - All, - Below, - None, -} - -#[derive(Debug, Clone, Copy)] -pub enum ComponentFocus { - Unfocused, - Focused, -} - -pub fn color_from_focus( - focus: ComponentFocus, - colors: &hac_colors::Colors, -) -> ratatui::style::Color { - match focus { - ComponentFocus::Focused => colors.normal.red, - ComponentFocus::Unfocused => colors.normal.white, - } -} - -impl From for ComponentFocus { - fn from(value: bool) -> Self { - match value { - true => ComponentFocus::Focused, - false => ComponentFocus::Unfocused, - } - } -} diff --git a/hac-client/src/components/input.rs b/hac-client/src/components/input.rs new file mode 100644 index 0000000..97f00dd --- /dev/null +++ b/hac-client/src/components/input.rs @@ -0,0 +1,104 @@ +use std::borrow::Cow; + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget}; + +use crate::HacColors; + +#[derive(Debug, Default)] +pub struct Input<'input> { + label: Option>, + label_style: Style, + value: Option>, + value_style: Style, + border_style: Style, + colors: HacColors, +} + +impl<'input> Input<'input> { + pub fn new(value: Option, label: Option, colors: HacColors) -> Self + where + T: Into>, + { + Self { + value: value.map(Into::into), + value_style: Style::default().fg(colors.normal.white), + label: label.map(Into::into), + label_style: Style::default().fg(colors.bright.black), + border_style: Style::default().fg(colors.normal.white), + colors, + } + } + + pub fn label_style(self, style: Style) -> Self { + Self { + value: self.value, + value_style: self.value_style, + label: self.label, + label_style: style, + border_style: self.border_style, + colors: self.colors, + } + } + + pub fn value_style(self, style: Style) -> Self { + Self { + value: self.value, + value_style: style, + label: self.label, + label_style: self.label_style, + border_style: self.border_style, + colors: self.colors, + } + } + + pub fn border_style(self, style: Style) -> Self { + Self { + value: self.value, + value_style: self.value_style, + label: self.label, + label_style: self.label_style, + colors: self.colors, + border_style: style, + } + } + + fn has_label(&self) -> bool { + self.label.as_ref().is_some_and(|label| !label.is_empty()) + } +} + +impl Widget for Input<'_> { + fn render(self, mut area: Rect, buf: &mut Buffer) + where + Self: Sized, + { + let min_height = if self.has_label() { 4 } else { 3 }; + if area.height < min_height { + panic!("input needs at least a height of 4, but got {area:?}"); + } + + if self.has_label() { + let size = Rect::new(area.x, area.y, area.width, 1); + let label = self.label.as_ref().unwrap().to_string(); + let label = Line::from(Span::from(label).style(self.label_style)); + label.render(size, buf); + area.y += 1; + area.height -= 1; + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(self.border_style) + .style(self.label_style); + + let value = self.value.as_ref().map(|v| v.to_string()).unwrap_or_default(); + let size = Rect::new(area.x, area.y, area.width, 3); + let value = Line::from(Span::from(value).style(self.value_style)); + Clear.render(size, buf); + Paragraph::new(value).block(block).render(size, buf); + } +} diff --git a/hac-client/src/components/key_value_pair.rs b/hac-client/src/components/key_value_pair.rs deleted file mode 100644 index ba2e4c5..0000000 --- a/hac-client/src/components/key_value_pair.rs +++ /dev/null @@ -1,37 +0,0 @@ -use std::borrow::Cow; - -use ratatui::style::Style; -use ratatui::widgets::{Block, Borders, Paragraph}; - -use super::component_styles::{color_from_focus, ComponentBorder, ComponentFocus}; - -pub fn key_value_pair<'a, T>( - key: T, - value: T, - border_kind: ComponentBorder, - focus: ComponentFocus, - colors: &hac_colors::Colors, -) -> (Paragraph<'a>, Paragraph<'a>) -where - T: Into>, -{ - let block = Block::default(); - let block = block.borders(match border_kind { - ComponentBorder::None => Borders::NONE, - ComponentBorder::All => Borders::ALL, - ComponentBorder::Below => Borders::BOTTOM, - }); - let block = block.border_style(match border_kind { - ComponentBorder::None => Style::default(), - ComponentBorder::All => Style::default().fg(color_from_focus(focus, colors)), - ComponentBorder::Below => Style::default().fg(color_from_focus(focus, colors)), - }); - - let style = Style::default(); - let style = style.fg(color_from_focus(focus, colors)); - - let key = Paragraph::new(key.into()).block(block.clone()).style(style); - let value = Paragraph::new(value.into()).block(block).style(style); - - (key, value) -} diff --git a/hac-client/src/components/list_item.rs b/hac-client/src/components/list_item.rs index ca86a79..0cc7138 100644 --- a/hac-client/src/components/list_item.rs +++ b/hac-client/src/components/list_item.rs @@ -1,53 +1,96 @@ use std::borrow::Cow; -use ratatui::style::{Style, Stylize}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget}; -use crate::components::component_styles::{color_from_focus, ComponentBorder, ComponentFocus}; +use crate::HacColors; -pub enum ListItemKind { - _Regular, - Enumerated(usize), +#[derive(Debug, Clone)] +pub struct ListItem<'item> { + title: Cow<'item, str>, + description: Option>, + title_style: Style, + desc_style: Style, + selected: bool, + colors: HacColors, } -pub fn list_item<'a, T>( - text: T, - focus: ComponentFocus, - border_kind: ComponentBorder, - kind: ListItemKind, - colors: &hac_colors::Colors, -) -> Paragraph<'a> -where - T: Into>, -{ - let block = Block::default(); - let block = block.borders(match border_kind { - ComponentBorder::None => Borders::NONE, - ComponentBorder::All => Borders::ALL, - ComponentBorder::Below => Borders::BOTTOM, - }); - let block = block.border_style(match border_kind { - ComponentBorder::None => Style::default(), - ComponentBorder::All => Style::default().fg(color_from_focus(focus, colors)), - ComponentBorder::Below => Style::default().fg(color_from_focus(focus, colors)), - }); - - let style = Style::default(); - let style = style.fg(color_from_focus(focus, colors)); - let style = style.bg(colors.normal.black); - - let text = match kind { - ListItemKind::_Regular => Line::from(Span::from(text.into())), - ListItemKind::Enumerated(idx) => { - let text = format!("{}{}", text.into(), " ".repeat(80)); - Line::from(vec![ - Span::from(idx.to_string()).fg(colors.normal.red), - Span::from(" "), - Span::from(text), - ]) +impl<'item> ListItem<'item> { + pub fn new(title: T, description: Option, colors: HacColors) -> Self + where + T: Into>, + { + Self { + title: title.into(), + description: description.map(Into::into), + title_style: Style::default(), + desc_style: Style::default(), + selected: false, + colors, } - }; + } - Paragraph::new(text).block(block).style(style) + pub fn title_style(self, style: Style) -> Self { + Self { + title: self.title, + description: self.description, + title_style: style, + desc_style: self.desc_style, + selected: self.selected, + colors: self.colors, + } + } + + pub fn desc_style(self, style: Style) -> Self { + Self { + title: self.title, + description: self.description, + title_style: self.title_style, + desc_style: style, + selected: self.selected, + colors: self.colors, + } + } + + pub fn select(self) -> Self { + Self { + title: self.title, + description: self.description, + title_style: self.title_style, + desc_style: self.desc_style, + selected: true, + colors: self.colors, + } + } +} + +impl Widget for ListItem<'_> { + fn render(self, area: Rect, buf: &mut Buffer) + where + Self: Sized, + { + let mut parts = Vec::with_capacity(2); + parts.push(Line::from(self.title.to_string()).style(self.title_style)); + + if let Some(description) = self.description { + parts.push(Line::from(description.to_string()).style(self.desc_style)); + } + + let mut block = Block::default().padding(Padding::left(1)); + if self.selected { + block = block + .borders(Borders::LEFT) + .border_style( + Style::default() + .bg(self.colors.primary.background) + .fg(self.colors.bright.red), + ) + .style(self.title_style); + } + + Paragraph::new(parts).block(block).render(area, buf); + } } diff --git a/hac-client/src/components/mod.rs b/hac-client/src/components/mod.rs new file mode 100644 index 0000000..f8bd870 --- /dev/null +++ b/hac-client/src/components/mod.rs @@ -0,0 +1,4 @@ +pub mod blending_list; +pub mod input; +pub mod list_item; +pub mod spinner; diff --git a/hac-client/src/pages/spinner.rs b/hac-client/src/components/spinner.rs similarity index 100% rename from hac-client/src/pages/spinner.rs rename to hac-client/src/components/spinner.rs diff --git a/hac-client/src/event_pool.rs b/hac-client/src/event_pool.rs index 0c035e7..187ec8d 100755 --- a/hac-client/src/event_pool.rs +++ b/hac-client/src/event_pool.rs @@ -1,52 +1,41 @@ use crossterm::event::{Event as CrosstermEvent, KeyEventKind}; use futures::{FutureExt, StreamExt}; use ratatui::layout::Rect; -use std::ops::Div; #[derive(Debug, Clone, PartialEq)] pub enum Event { Key(crossterm::event::KeyEvent), Resize(Rect), - Tick, - Render, } /// Core component responsible for pooling events from crossterm and sending /// them over to be handled #[derive(Debug)] pub struct EventPool { - event_rx: tokio::sync::mpsc::UnboundedReceiver, - event_tx: tokio::sync::mpsc::UnboundedSender, - frame_rate: f64, - tick_rate: f64, + event_rx: std::sync::mpsc::Receiver, + event_tx: std::sync::mpsc::Sender, } -impl EventPool { - pub fn new(frame_rate: f64, tick_rate: f64) -> Self { - let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel(); +impl Default for EventPool { + fn default() -> Self { + Self::new() + } +} - EventPool { - event_rx, - event_tx, - frame_rate, - tick_rate, - } +impl EventPool { + pub fn new() -> Self { + let (event_tx, event_rx) = std::sync::mpsc::channel(); + EventPool { event_rx, event_tx } } #[cfg_attr(test, mutants::skip)] pub fn start(&mut self) { - let render_delay = std::time::Duration::from_secs_f64(1.0.div(self.frame_rate)); - let tick_delay = std::time::Duration::from_secs_f64(1.0.div(self.tick_rate)); - let event_tx = self.event_tx.clone(); + tokio::spawn(async move { let mut reader = crossterm::event::EventStream::new(); - let mut render_interval = tokio::time::interval(render_delay); - let mut tick_interval = tokio::time::interval(tick_delay); loop { - let render_delay = render_interval.tick(); - let tick_delay = tick_interval.tick(); let crossterm_event = reader.next().fuse(); tokio::select! { @@ -63,19 +52,13 @@ impl EventPool { _ => {} } } - _ = tick_delay => { - event_tx.send(Event::Tick).expect("failed to send event through channel"); - }, - _ = render_delay => { - event_tx.send(Event::Render).expect("failed to send event through channel"); - }, } } }); } #[cfg_attr(test, mutants::skip)] - pub async fn next(&mut self) -> Option { - self.event_rx.recv().await + pub fn next_event(&mut self) -> Option { + self.event_rx.try_recv().ok() } } diff --git a/hac-client/src/icons.rs b/hac-client/src/icons.rs new file mode 100644 index 0000000..6e3dc49 --- /dev/null +++ b/hac-client/src/icons.rs @@ -0,0 +1,7 @@ +pub struct Icons; + +#[rustfmt::skip] +impl Icons { + pub const FOLDER: char = '\u{f07b}'; //  + pub const FOLDER_OPEN: char = '\u{f07c}'; //  +} diff --git a/hac-client/src/lib.rs b/hac-client/src/lib.rs index 03d2045..1314b93 100755 --- a/hac-client/src/lib.rs +++ b/hac-client/src/lib.rs @@ -2,6 +2,20 @@ pub mod app; mod ascii; mod components; pub mod event_pool; +mod icons; pub mod pages; -pub mod screen_manager; +mod renderable; +mod router; pub mod utils; + +use std::cell::RefCell; +use std::rc::Rc; + +use hac_colors::Colors; +use hac_config::Config; + +pub static MIN_WIDTH: u16 = 80; +pub static MIN_HEIGHT: u16 = 30; + +pub type HacConfig = Rc>; +pub type HacColors = Rc; diff --git a/hac-client/src/main.rs b/hac-client/src/main.rs index 47dddb2..bba722a 100755 --- a/hac-client/src/main.rs +++ b/hac-client/src/main.rs @@ -1,9 +1,12 @@ +use std::cell::RefCell; +use std::rc::Rc; + use hac_cli::RuntimeBehavior; use hac_client::app; -use hac_core::collection::collection; fn setup_tracing() -> anyhow::Result { - let (data_dir, logfile) = hac_config::log_file(); + let logfile = hac_config::LOGFILE; + let data_dir = hac_loader::data_dir(); let appender = tracing_appender::rolling::never(data_dir, logfile); let (writer, guard) = tracing_appender::non_blocking(appender); let subscriber = tracing_subscriber::FmtSubscriber::builder() @@ -21,31 +24,20 @@ fn setup_tracing() -> anyhow::Result anyhow::Result<()> { let runtime_behavior = hac_cli::Cli::parse_args(); - match runtime_behavior { - RuntimeBehavior::PrintConfigPath => hac_cli::Cli::print_config_path( - hac_config::get_config_dir_path(), - hac_config::get_usual_path(), - ), - RuntimeBehavior::PrintDataPath => { - hac_cli::Cli::print_data_path(hac_config::get_collections_dir()) - } - RuntimeBehavior::DumpDefaultConfig => { - hac_cli::Cli::print_default_config(hac_config::default_as_str()) - } - _ => {} - } - let dry_run = runtime_behavior.eq(&RuntimeBehavior::DryRun); - let _guard = setup_tracing()?; - hac_config::get_or_create_data_dir(); - let config = hac_config::load_config(); + let guard = setup_tracing()?; + hac_loader::get_or_create_data_dir(); + hac_loader::get_or_create_collections_dir(); + hac_loader::collection_loader::get_collections_metadata()?; let colors = hac_colors::Colors::default(); - let mut collections = collection::get_collections_from_config()?; - collections.sort_by_key(|key| key.info.name.clone()); - let mut app = app::App::new(&colors, collections, &config, dry_run)?; + let mut config = hac_config::load_config(); + config.dry_run = dry_run; + + let mut app = app::App::new(Rc::new(RefCell::new(config)), Rc::new(colors))?; app.run().await?; + _ = guard; Ok(()) } diff --git a/hac-client/src/pages/collection_dashboard/collection_dashboard.rs b/hac-client/src/pages/collection_dashboard/collection_dashboard.rs deleted file mode 100755 index d8bb7e1..0000000 --- a/hac-client/src/pages/collection_dashboard/collection_dashboard.rs +++ /dev/null @@ -1,1016 +0,0 @@ -use hac_core::{collection::types::Collection, command::Command}; - -use crate::pages::collection_dashboard::collection_list::{CollectionList, CollectionListState}; -use crate::pages::collection_dashboard::new_collection_form::{ - FormFocus, FormState, NewCollectionForm, -}; -use crate::pages::confirm_popup::ConfirmPopup; -use crate::pages::error_popup::ErrorPopup; -use crate::pages::overlay::{draw_overlay, make_overlay}; -use crate::pages::{Eventful, Renderable}; - -use std::ops::{Add, Div, Not, Sub}; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use ratatui::layout::{Alignment, Constraint, Direction, Flex, Layout, Rect}; -use ratatui::style::{Style, Stylize}; -use ratatui::text::Line; -use ratatui::widgets::{Block, Clear, Padding, Paragraph, StatefulWidget, Widget, Wrap}; -use ratatui::Frame; -use tokio::sync::mpsc::UnboundedSender; -use tui_big_text::{BigText, PixelSize}; - -#[derive(Debug, PartialEq)] -struct DashboardLayout { - collections_pane: Rect, - hint_pane: Rect, - help_popup: Rect, - title_pane: Rect, - confirm_popup: Rect, - form_popup: Rect, - error_popup: Rect, -} - -#[derive(Debug)] -pub struct CollectionDashboard<'a> { - layout: DashboardLayout, - collections: Vec, - - list: CollectionList<'a>, - list_state: CollectionListState, - form_state: FormState, - colors: &'a hac_colors::Colors, - filter: String, - pane_focus: PaneFocus, - pub command_sender: Option>, - error_message: String, - dry_run: bool, -} - -#[derive(Debug, PartialEq, Eq)] -enum PaneFocus { - List, - Form, - Error, - Prompt, - Help, - Filter, -} - -impl<'a> CollectionDashboard<'a> { - pub fn new( - size: Rect, - colors: &'a hac_colors::Colors, - collections: Vec, - dry_run: bool, - ) -> anyhow::Result { - let mut list_state = CollectionListState::new(collections.clone()); - collections - .is_empty() - .not() - .then(|| list_state.select(Some(0))); - - Ok(CollectionDashboard { - list_state, - form_state: FormState::default(), - colors, - layout: build_layout(size), - collections, - list: CollectionList::new(colors), - filter: String::new(), - command_sender: None, - error_message: String::default(), - pane_focus: PaneFocus::List, - dry_run, - }) - } - - pub fn display_error(&mut self, message: String) { - self.pane_focus = PaneFocus::Error; - self.error_message = message; - } - - fn filter_list(&mut self) { - self.list_state.set_items( - self.collections - .clone() - .into_iter() - .filter(|s| s.info.name.contains(&self.filter)) - .collect(), - ); - self.list_state.select(None); - } - - fn handle_filter_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { - match (key_event.code, key_event.modifiers) { - (KeyCode::Char('c'), KeyModifiers::CONTROL) | (KeyCode::Esc, _) => { - self.pane_focus = PaneFocus::List; - self.filter = String::new(); - self.filter_list(); - } - (KeyCode::Backspace, _) => { - if self.filter.is_empty() { - self.pane_focus = PaneFocus::List; - } - self.filter.pop(); - self.filter_list(); - } - (KeyCode::Enter, _) => { - self.pane_focus = PaneFocus::List; - self.filter_list(); - } - (KeyCode::Char(c), _) => { - self.filter.push(c); - self.filter_list(); - } - _ => {} - }; - - Ok(None) - } - - fn handle_list_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { - match key_event.code { - KeyCode::Enter => { - return Ok(self - .list_state - .items - .is_empty() - .not() - .then(|| { - self.list_state - .selected() - .and_then(|i| self.collections.get(i)) - .expect( - "user should never be allowed to select a non existing collection", - ) - }) - .map(|collection| { - tracing::debug!("selected collection: {}", collection.info.name); - Command::SelectCollection(collection.clone()) - })); - } - KeyCode::Char('d') => { - if self.list_state.selected().is_some() { - self.pane_focus = PaneFocus::Prompt; - } - } - KeyCode::Char('n') | KeyCode::Char('c') => { - self.pane_focus = PaneFocus::Form; - } - KeyCode::Char('h') | KeyCode::Left => { - if !self.list_state.items.is_empty() { - self.list_state.select( - self.list_state - .selected() - .map(|i| i.saturating_sub(1)) - .or(Some(0)), - ); - } - } - KeyCode::Char('j') | KeyCode::Down => { - if !self.list_state.items.is_empty() { - self.list_state.select( - self.list_state - .selected() - .map(|i| { - usize::min( - self.list_state.items.len() - 1, - i + self.list.items_per_row(&self.layout.collections_pane), - ) - }) - .or(Some(0)), - ); - } - } - KeyCode::Char('k') | KeyCode::Up => { - if !self.list_state.items.is_empty() { - self.list_state.select( - self.list_state - .selected() - .map(|i| { - i.saturating_sub( - self.list.items_per_row(&self.layout.collections_pane), - ) - }) - .or(Some(0)), - ); - } - } - KeyCode::Char('l') | KeyCode::Right => { - if !self.list_state.items.is_empty() { - self.list_state.select( - self.list_state - .selected() - .map(|i| usize::min(self.list_state.items.len().sub(1), i.add(1))) - .or(Some(0)), - ); - } - } - KeyCode::Char('?') => self.pane_focus = PaneFocus::Help, - KeyCode::Char('/') => self.pane_focus = PaneFocus::Filter, - _ => {} - }; - Ok(None) - } - - fn handle_form_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { - match (key_event.code, key_event.modifiers) { - (KeyCode::Tab, _) => match self.form_state.focused_field { - FormFocus::Name => self.form_state.focused_field = FormFocus::Description, - FormFocus::Description => self.form_state.focused_field = FormFocus::Confirm, - FormFocus::Confirm => self.form_state.focused_field = FormFocus::Cancel, - FormFocus::Cancel => self.form_state.focused_field = FormFocus::Name, - }, - (KeyCode::Char(c), _) => match self.form_state.focused_field { - FormFocus::Name => self.form_state.name.push(c), - FormFocus::Description => self.form_state.description.push(c), - _ => {} - }, - (KeyCode::Enter, _) => match self.form_state.focused_field { - FormFocus::Confirm => { - let name = self.form_state.name.clone(); - let description = self.form_state.description.clone(); - - let sender_copy = self - .command_sender - .clone() - .expect("should always have a sender at this point"); - - let dry_run = self.dry_run; - - tokio::spawn(async move { - match hac_core::fs::create_collection(name, description, dry_run).await { - Ok(collection) => { - if sender_copy - .send(Command::CreateCollection(collection)) - .is_err() - { - tracing::error!("failed to send command through channel"); - std::process::abort(); - } - } - Err(e) => { - if sender_copy.send(Command::Error(e.to_string())).is_err() { - tracing::error!("failed to send error command through channel"); - std::process::abort(); - } - } - } - }); - } - FormFocus::Cancel => { - self.pane_focus = PaneFocus::List; - self.form_state.reset(); - } - _ => {} - }, - (KeyCode::Backspace, _) => match self.form_state.focused_field { - FormFocus::Name => { - self.form_state.name.pop(); - } - FormFocus::Description => { - self.form_state.description.pop(); - } - _ => {} - }, - _ => {} - } - Ok(None) - } - - #[tracing::instrument(skip_all)] - fn handle_confirm_popup_key_event( - &mut self, - key_event: KeyEvent, - ) -> anyhow::Result> { - match key_event.code { - KeyCode::Char('y') => { - let selected = self - .list_state - .selected() - .expect("deleting when nothing is selected should never happen"); - let collection = self - .collections - .get(selected) - .expect("should never attempt to delete a non existing item"); - let path = collection.path.clone(); - - if !self.dry_run { - tokio::spawn(async move { - tracing::debug!("attempting to delete collection: {:?}", path); - hac_core::fs::delete_collection(&path) - .await - .expect("failed to delete collection from filesystem"); - }); - } - - self.collections.remove(selected); - self.list_state.set_items(self.collections.clone()); - self.list_state.select(None); - self.pane_focus = PaneFocus::List; - } - KeyCode::Char('n') => { - self.pane_focus = PaneFocus::List; - } - _ => {} - }; - - Ok(None) - } - - fn handle_error_popup_key_event( - &mut self, - key_event: KeyEvent, - ) -> anyhow::Result> { - match key_event.code { - KeyCode::Char('o') | KeyCode::Esc | KeyCode::Enter => { - self.pane_focus = PaneFocus::List; - } - _ => {} - }; - - Ok(None) - } - - fn draw_hint_text(&self, frame: &mut Frame) { - let hint = - "[h/j/k/l to move] [n -> new] [enter -> select item] [? -> help] [ -> quit]" - .fg(self.colors.normal.magenta) - .into_centered_line(); - - frame.render_widget(hint, self.layout.hint_pane); - } - - fn draw_help_popup(&self, frame: &mut Frame) { - make_overlay(self.colors, self.colors.primary.background, 0.2, frame); - - let lines = vec![ - Line::from(vec![ - "h/".fg(self.colors.bright.magenta), - " - select left item".into(), - ]), - Line::from(vec![ - "j/".fg(self.colors.bright.magenta), - " - select item below".into(), - ]), - Line::from(vec![ - "k/".fg(self.colors.bright.magenta), - " - select item above".into(), - ]), - Line::from(vec![ - "l/".fg(self.colors.bright.magenta), - " - select right item".into(), - ]), - Line::from(vec![ - "n/c".fg(self.colors.bright.magenta), - " - creates a new collection".into(), - ]), - Line::from(vec![ - "d".fg(self.colors.bright.magenta), - " - deletes the selected collection".into(), - ]), - Line::from(vec![ - "?".fg(self.colors.bright.magenta), - " - toggle this help window".into(), - ]), - Line::from(vec![ - "enter".fg(self.colors.bright.magenta), - " - select item under cursor".into(), - ]), - Line::from(vec![ - "/".fg(self.colors.bright.magenta), - " - enter filter mode".into(), - ]), - Line::from(vec![ - "".fg(self.colors.bright.magenta), - " - quits the application".into(), - ]), - Line::from(""), - Line::from("press any key to go back".fg(self.colors.normal.magenta)).centered(), - ]; - - frame.render_widget( - Paragraph::new(lines) - .wrap(Wrap { trim: true }) - .block(Block::default().padding(Padding::new(2, 2, 1, 1))), - self.layout.help_popup, - ); - } - - fn draw_filter_prompt(&self, frame: &mut Frame) { - let filter = Line::from(format!("/{}", self.filter)); - frame.render_widget(filter, self.layout.hint_pane); - } - - fn draw_collection_list(&mut self, frame: &mut Frame) { - frame.render_stateful_widget( - self.list.clone(), - self.layout.collections_pane, - &mut self.list_state, - ); - } - - fn draw_no_matches_text(&self, frame: &mut Frame) -> anyhow::Result<()> { - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Fill(1), - Constraint::Length(8), - Constraint::Fill(1), - ]) - .split(self.layout.collections_pane)[1]; - - let no_matches = BigText::builder() - .pixel_size(PixelSize::Quadrant) - .style(Style::default().fg(self.colors.normal.magenta)) - .lines(vec!["No matches".into()]) - .alignment(Alignment::Center) - .build()?; - - frame.render_widget(no_matches, layout); - - Ok(()) - } - - fn draw_empty_message(&self, frame: &mut Frame) -> anyhow::Result<()> { - let size = Layout::default() - .direction(Direction::Vertical) - .flex(Flex::Center) - .constraints([ - Constraint::Fill(1), - Constraint::Length(8), - Constraint::Fill(1), - ]) - .split(self.layout.collections_pane)[1]; - - let empty_message = BigText::builder() - .pixel_size(PixelSize::Quadrant) - .style(Style::default().fg(self.colors.normal.magenta)) - .lines(vec!["No collections".into()]) - .alignment(Alignment::Center) - .build()?; - - frame.render_widget(empty_message, size); - - Ok(()) - } - - fn draw_background(&self, size: Rect, frame: &mut Frame) { - frame.render_widget(Clear, size); - frame.render_widget(Block::default().bg(self.colors.primary.background), size); - } - - fn draw_error_popup(&self, frame: &mut Frame) { - let popup = ErrorPopup::new(self.error_message.clone(), self.colors); - popup.render(self.layout.error_popup, frame.buffer_mut()); - } - - fn draw_form_popup(&mut self, size: Rect, frame: &mut Frame) { - self.draw_background(size, frame); - draw_overlay(self.colors, size, "新", frame); - - let form = NewCollectionForm::new(self.colors); - form.render( - self.layout.form_popup, - frame.buffer_mut(), - &mut self.form_state, - ); - } - - fn draw_delete_prompt(&self, frame: &mut Frame) { - let selected_index = self - .list_state - .selected() - .expect("attempted to open confirm popup without an item selected"); - let selected_item_name = &self - .collections - .get(selected_index) - .expect("should never be able to have an out of bounds selection") - .info - .name; - - let confirm_popup = ConfirmPopup::new( - format!( - "You really want to delete collection {}?", - selected_item_name - ), - self.colors, - ); - confirm_popup.render(self.layout.confirm_popup, frame.buffer_mut()); - } - - fn draw_title(&self, frame: &mut Frame) -> anyhow::Result<()> { - let title = BigText::builder() - .pixel_size(PixelSize::Quadrant) - .style(Style::default().fg(self.colors.normal.red)) - .lines(vec!["Select a collection".into()]) - .alignment(Alignment::Center) - .build()?; - - frame.render_widget(title, self.layout.title_pane); - - Ok(()) - } -} - -impl Renderable for CollectionDashboard<'_> { - fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()> { - self.draw_background(size, frame); - self.draw_title(frame)?; - - match ( - self.collections.is_empty(), - self.list_state.items.is_empty(), - ) { - (false, false) => self.draw_collection_list(frame), - (false, true) => self.draw_no_matches_text(frame)?, - (true, true) => self.draw_empty_message(frame)?, - (true, false) => unreachable!(), - } - - match self.pane_focus { - PaneFocus::Error => self.draw_error_popup(frame), - PaneFocus::Form => self.draw_form_popup(size, frame), - PaneFocus::Filter => self.draw_filter_prompt(frame), - PaneFocus::Help => self.draw_help_popup(frame), - PaneFocus::Prompt => self.draw_delete_prompt(frame), - PaneFocus::List => self.draw_hint_text(frame), - } - - Ok(()) - } - - fn register_command_handler(&mut self, sender: UnboundedSender) -> anyhow::Result<()> { - self.command_sender = Some(sender.clone()); - Ok(()) - } - - fn resize(&mut self, new_size: Rect) { - self.layout = build_layout(new_size); - } -} - -impl Eventful for CollectionDashboard<'_> { - type Result = Command; - - fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { - if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { - return Ok(Some(Command::Quit)); - }; - - match self.pane_focus { - PaneFocus::List => self.handle_list_key_event(key_event), - PaneFocus::Form => self.handle_form_key_event(key_event), - PaneFocus::Error => self.handle_error_popup_key_event(key_event), - PaneFocus::Prompt => self.handle_confirm_popup_key_event(key_event), - PaneFocus::Filter => self.handle_filter_key_event(key_event), - PaneFocus::Help => { - self.pane_focus = PaneFocus::List; - Ok(None) - } - } - } -} - -fn build_layout(size: Rect) -> DashboardLayout { - let size = Rect::new(size.x + 1, size.y, size.width - 1, size.height); - let [top, help_pane] = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Fill(1), Constraint::Length(1)]) - .areas(size); - - let [_, title_pane, collections_pane] = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Length(5), - Constraint::Fill(1), - ]) - .areas(top); - - let help_popup = Rect::new( - size.width.div(2).saturating_sub(25), - size.height.div(2).saturating_sub(7), - 50, - 14, - ); - let confirm_popup = Rect::new( - size.width.div(4), - size.height.div(2).saturating_sub(4), - size.width.div(2), - 8, - ); - let form_popup = Rect::new( - size.width.div(4), - size.height.div(2).saturating_sub(7), - size.width.div(2), - 14, - ); - let error_popup = Rect::new( - size.width.div(4), - size.height.div(2).saturating_sub(10), - size.width.div(2), - 20, - ); - - DashboardLayout { - collections_pane, - hint_pane: help_pane, - title_pane, - help_popup, - confirm_popup, - form_popup, - error_popup, - } -} - -#[cfg(test)] -mod tests { - use hac_core::collection; - use ratatui::{backend::TestBackend, buffer::Cell, Terminal}; - use std::{ - fs::{create_dir, File}, - io::Write, - }; - use tempfile::{tempdir, TempDir}; - - use super::*; - - fn setup_temp_collections(amount: usize) -> (TempDir, String) { - let tmp_data_dir = tempdir().expect("Failed to create temp data dir"); - - let tmp_dir = tmp_data_dir.path().join("collections"); - create_dir(&tmp_dir).expect("Failed to create collections directory"); - - for i in 0..amount { - let file_path = tmp_dir.join(format!("test_collection_{}.json", i)); - let mut tmp_file = File::create(&file_path).expect("Failed to create file"); - - write!( - tmp_file, - r#"{{"info": {{ "name": "test_collection_{}", "description": "test_description_{}" }}}}"#, - i, i - ).expect("Failed to write to file"); - - tmp_file.flush().expect("Failed to flush file"); - } - - (tmp_data_dir, tmp_dir.to_string_lossy().to_string()) - } - - fn feed_keys(dashboard: &mut CollectionDashboard, events: &[KeyEvent]) { - for event in events { - _ = dashboard.handle_key_event(*event); - } - } - - #[test] - fn test_build_layout() { - let size = Rect::new(0, 0, 80, 24); - let expected = DashboardLayout { - collections_pane: Rect::new(1, 6, 79, 17), - hint_pane: Rect::new(1, 23, 79, 1), - title_pane: Rect::new(1, 1, 79, 5), - help_popup: Rect::new(14, 5, 50, 14), - confirm_popup: Rect::new(19, 8, 39, 8), - form_popup: Rect::new(19, 5, 39, 14), - error_popup: Rect::new(19, 2, 39, 20), - }; - - let layout = build_layout(size); - - assert_eq!(layout, expected); - } - - #[test] - fn test_open_close_help() { - let size = Rect::new(0, 0, 80, 24); - let colors = hac_colors::Colors::default(); - let (_guard, path) = setup_temp_collections(1); - let collection = collection::collection::get_collections(path).unwrap(); - - let mut dashboard = CollectionDashboard::new(size, &colors, collection, false).unwrap(); - - assert_eq!(dashboard.collections.len(), 1); - assert_eq!(dashboard.list_state.selected(), Some(0)); - - assert_eq!(dashboard.pane_focus, PaneFocus::List); - - feed_keys( - &mut dashboard, - &[KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)], - ); - - assert_eq!(dashboard.pane_focus, PaneFocus::Help); - - feed_keys( - &mut dashboard, - &[KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)], - ); - - assert_eq!(dashboard.pane_focus, PaneFocus::List); - } - - #[test] - fn test_actions_without_any_collections() { - let size = Rect::new(0, 0, 80, 24); - let colors = hac_colors::Colors::default(); - let mut dashboard = CollectionDashboard::new(size, &colors, vec![], false).unwrap(); - - assert!(dashboard.collections.is_empty()); - assert_eq!(dashboard.list_state.selected(), None); - - feed_keys( - &mut dashboard, - &[ - KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), - ], - ); - - assert!(dashboard.collections.is_empty()); - assert_eq!(dashboard.list_state.selected(), None); - } - - #[test] - fn test_filtering_list() { - let size = Rect::new(0, 0, 80, 24); - let colors = hac_colors::Colors::default(); - let (_guard, path) = setup_temp_collections(10); - let collections = collection::collection::get_collections(path).unwrap(); - - let mut dashboard = CollectionDashboard::new(size, &colors, collections, false).unwrap(); - - assert_eq!(dashboard.collections.len(), 10); - assert_eq!(dashboard.list_state.selected(), Some(0)); - - feed_keys( - &mut dashboard, - &[ - // enter filtering - search for non-existing item - KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE), - ], - ); - - assert_eq!(dashboard.pane_focus, PaneFocus::Filter); - assert_eq!(dashboard.list_state.items.len(), 0); - - feed_keys( - &mut dashboard, - &[ - // erase until filtering is cancelled - KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), - ], - ); - - assert_eq!(dashboard.pane_focus, PaneFocus::List); - assert_eq!(dashboard.list_state.items.len(), 10); - - feed_keys( - &mut dashboard, - &[ - // enter filtering again and cancel with hotkey - KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), - KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), - ], - ); - - assert_eq!(dashboard.pane_focus, PaneFocus::List); - assert_eq!(dashboard.list_state.items.len(), 10); - - feed_keys( - &mut dashboard, - &[ - // enter filtering again and actually filter the list - KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - ], - ); - - assert_eq!(dashboard.pane_focus, PaneFocus::List); - assert_eq!(dashboard.list_state.items.len(), 1); - } - - #[test] - fn test_moving_out_of_bounds() { - let size = Rect::new(0, 0, 80, 24); - let colors = hac_colors::Colors::default(); - let (_guard, path) = setup_temp_collections(3); - let collections = collection::collection::get_collections(path).unwrap(); - - let mut dashboard = CollectionDashboard::new(size, &colors, collections, false).unwrap(); - - feed_keys( - &mut dashboard, - &[ - // moving down until end is reached, twice more - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), - ], - ); - - assert_eq!(dashboard.list_state.selected(), Some(2)); - - feed_keys( - &mut dashboard, - &[ - // moving right until beginning is reached, twice more - KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), - ], - ); - - assert_eq!(dashboard.list_state.selected(), Some(0)); - } - - #[test] - fn test_creating_new_collections() { - let size = Rect::new(0, 0, 80, 24); - let colors = hac_colors::Colors::default(); - let (_guard, path) = setup_temp_collections(3); - let collections = collection::collection::get_collections(path).unwrap(); - - let mut dashboard = CollectionDashboard::new(size, &colors, collections, false).unwrap(); - - feed_keys( - &mut dashboard, - &[KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)], - ); - - assert_eq!(dashboard.pane_focus, PaneFocus::Form); - - feed_keys( - &mut dashboard, - &[ - // going to the cancel button, and closing the form, typing something to ensure - // state reset - KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - ], - ); - - assert_eq!(dashboard.pane_focus, PaneFocus::List); - - feed_keys( - &mut dashboard, - &[KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)], - ); - - assert_eq!(dashboard.pane_focus, PaneFocus::Form); - - feed_keys( - &mut dashboard, - &[ - // filling in the name field - KeyEvent::new(KeyCode::Char('H'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE), - ], - ); - - assert_eq!(dashboard.form_state.name, "Hello"); - - feed_keys( - &mut dashboard, - &[ - // filling in the description - KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('W'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), - ], - ); - - assert_eq!(dashboard.form_state.name, "Hello"); - assert_eq!(dashboard.form_state.description, "World"); - } - - #[test] - fn test_prompt_delete_collections() { - let size = Rect::new(0, 0, 80, 24); - let colors = hac_colors::Colors::default(); - let (_guard, path) = setup_temp_collections(3); - let collections = collection::collection::get_collections(path).unwrap(); - let mut dashboard = CollectionDashboard::new(size, &colors, collections, false).unwrap(); - - feed_keys( - &mut dashboard, - &[KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)], - ); - - assert_eq!(dashboard.pane_focus, PaneFocus::Prompt); - - feed_keys( - &mut dashboard, - &[KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)], - ); - - assert_eq!(dashboard.pane_focus, PaneFocus::List); - } - - #[test] - fn test_display_error() { - let size = Rect::new(0, 0, 80, 24); - let colors = hac_colors::Colors::default(); - let mut dashboard = CollectionDashboard::new(size, &colors, vec![], false).unwrap(); - - dashboard.display_error("any error message".into()); - - assert_eq!(dashboard.pane_focus, PaneFocus::Error); - assert_eq!(dashboard.error_message, "any error message"); - } - - #[test] - fn test_draw_background() { - let colors = hac_colors::Colors::default(); - let size = Rect::new(0, 0, 80, 22); - let dashboard = CollectionDashboard::new(size, &colors, vec![], false).unwrap(); - - let mut terminal = Terminal::new(TestBackend::new(80, 22)).unwrap(); - let mut frame = terminal.get_frame(); - - for cell in frame.buffer_mut().content.iter() { - assert_eq!(cell, &Cell::default()); - } - - dashboard.draw_background(size, &mut frame); - - for cell in frame.buffer_mut().content.iter() { - assert_eq!(cell.bg, colors.primary.background); - } - } - - #[test] - fn test_close_error_popup() { - let colors = hac_colors::Colors::default(); - let size = Rect::new(0, 0, 80, 22); - let (_guard, path) = setup_temp_collections(3); - let collections = collection::collection::get_collections(path).unwrap(); - let mut dashboard = CollectionDashboard::new(size, &colors, collections, false).unwrap(); - - dashboard.display_error("any_error_message".into()); - assert_eq!(dashboard.pane_focus, PaneFocus::Error); - feed_keys( - &mut dashboard, - &[KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)], - ); - - assert_eq!(dashboard.pane_focus, PaneFocus::List); - } - - #[test] - fn test_resizing() { - let colors = hac_colors::Colors::default(); - let size = Rect::new(0, 0, 80, 22); - let new_size = Rect::new(0, 0, 80, 24); - let (_guard, path) = setup_temp_collections(3); - let collections = collection::collection::get_collections(path).unwrap(); - let mut dashboard = CollectionDashboard::new(size, &colors, collections, false).unwrap(); - let expected = DashboardLayout { - collections_pane: Rect::new(1, 6, 79, 17), - hint_pane: Rect::new(1, 23, 79, 1), - title_pane: Rect::new(1, 1, 79, 5), - help_popup: Rect::new(14, 5, 50, 14), - confirm_popup: Rect::new(19, 8, 39, 8), - form_popup: Rect::new(19, 5, 39, 14), - error_popup: Rect::new(19, 2, 39, 20), - }; - - dashboard.resize(new_size); - assert_eq!(dashboard.layout, expected); - } -} diff --git a/hac-client/src/pages/collection_dashboard/collection_list.rs b/hac-client/src/pages/collection_dashboard/collection_list.rs deleted file mode 100755 index ea34e4a..0000000 --- a/hac-client/src/pages/collection_dashboard/collection_list.rs +++ /dev/null @@ -1,279 +0,0 @@ -use hac_core::collection::Collection; - -use std::collections::VecDeque; -use std::ops::{Add, Div, Mul}; - -use ratatui::buffer::Buffer; -use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect}; -use ratatui::style::{Style, Stylize}; -use ratatui::widgets::{ - Block, BorderType, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, - StatefulWidget, Widget, -}; - -#[derive(Debug)] -pub struct CollectionListState { - selected: Option, - pub(super) items: Vec, - scroll: usize, -} - -impl CollectionListState { - pub fn new(items: Vec) -> Self { - CollectionListState { - selected: None, - items, - scroll: 0, - } - } - - pub fn select(&mut self, index: Option) { - self.selected = index; - } - - pub fn selected(&self) -> Option { - self.selected - } - - pub fn set_items(&mut self, items: Vec) { - self.items = items; - } -} - -#[derive(Debug, Clone)] -pub struct CollectionList<'cl> { - colors: &'cl hac_colors::Colors, - min_col_width: u16, - row_height: u16, -} - -impl<'a> CollectionList<'a> { - pub fn new(colors: &'a hac_colors::Colors) -> Self { - CollectionList { - colors, - min_col_width: 30, - row_height: 4, - } - } - - pub fn items_per_row(&self, size: &Rect) -> usize { - (size.width.saturating_sub(1).div(self.min_col_width)).into() - } - - pub fn total_rows(&self, size: &Rect) -> usize { - (size.height.div(self.row_height)).into() - } - - fn build_layout(&self, size: &Rect) -> VecDeque { - let total_rows = self.total_rows(size); - let items_per_row = self.items_per_row(size); - - (0..total_rows) - .flat_map(|row| { - Layout::default() - .direction(Direction::Horizontal) - .flex(Flex::SpaceAround) - .constraints((0..items_per_row).map(|_| Constraint::Min(self.min_col_width))) - .split(Rect::new( - size.x, - size.y + (self.row_height.mul(row as u16)), - size.width, - self.row_height, - )) - .to_vec() - }) - .collect::>() - } - - fn build_card( - &self, - state: &CollectionListState, - collection: &Collection, - index: usize, - ) -> Paragraph<'_> { - let lines = vec![ - collection - .info - .name - .clone() - .fg(self.colors.normal.white) - .into(), - collection - .info - .description - .clone() - .unwrap_or_default() - .fg(self.colors.bright.yellow) - .into(), - ]; - - let border_color = if state - .selected - .is_some_and(|selected| selected.eq(&(index.add(state.scroll)))) - { - self.colors.bright.magenta - } else { - self.colors.primary.hover - }; - - Paragraph::new(lines).block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(border_color)), - ) - } -} - -impl StatefulWidget for CollectionList<'_> { - type State = CollectionListState; - - fn render(self, size: Rect, buf: &mut Buffer, state: &mut Self::State) { - let list_size = Rect::new(size.x, size.y, size.width.saturating_sub(3), size.height); - let scrollbar_size = Rect::new(size.width.saturating_sub(1), size.y, 1, size.height); - let mut rects = self.build_layout(&list_size); - - let mut scrollbar_state = - ScrollbarState::new(state.items.len().div(self.items_per_row(&size))) - .position(state.scroll); - - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .style(Style::default().fg(self.colors.normal.magenta)) - .begin_symbol(Some("↑")) - .end_symbol(Some("↓")); - - let items_on_display = self - .items_per_row(&list_size) - .mul(self.total_rows(&list_size)); - if let Some(index) = state.selected { - index - .gt(&items_on_display.saturating_sub(1).add(state.scroll)) - .then(|| state.scroll = state.scroll.add(self.items_per_row(&list_size))); - - state.scroll.gt(&0).then(|| { - index - .add(1) - .saturating_sub(state.scroll) - .eq(&0) - .then(|| state.scroll = state.scroll.saturating_sub(self.items_per_row(&size))); - }); - }; - - state - .items - .iter() - .skip(state.scroll) - .take(rects.len()) - .enumerate() - .map(|(i, collection)| self.build_card(state, collection, i)) - .for_each(|card| card.render(rects.pop_front().unwrap(), buf)); - - scrollbar.render(scrollbar_size, buf, &mut scrollbar_state); - } -} - -#[cfg(test)] -mod tests { - use std::ops::Sub; - - use super::*; - use hac_core::collection::types::*; - use ratatui::{backend::TestBackend, buffer::Cell, Terminal}; - - fn sample_collection() -> Collection { - Collection { - info: Info { - name: String::from("any_name"), - description: None, - }, - path: "any_path".into(), - requests: None, - } - } - - #[test] - fn test_build_layout() { - let colors = hac_colors::Colors::default(); - let collection_list = CollectionList::new(&colors); - let size = Rect::new(0, 0, 31, 10); - - let layout = collection_list.build_layout(&size); - - assert!(!layout.is_empty()); - assert_eq!(layout.len(), 2); - } - - #[test] - fn test_items_per_row() { - let colors = hac_colors::Colors::default(); - let collection_list = CollectionList::new(&colors); - let zero_items = Rect::new(0, 0, 30, 10); - let one_item = Rect::new(0, 0, 31, 10); - - let amount = collection_list.items_per_row(&zero_items); - assert_eq!(amount, 0); - - let amount = collection_list.items_per_row(&one_item); - assert_eq!(amount, 1); - } - - #[test] - fn test_build_card() { - let colors = hac_colors::Colors::default(); - let collection_list = CollectionList::new(&colors); - let collections = vec![Collection { - info: Info { - name: String::from("any_name"), - description: None, - }, - path: "any_path".into(), - requests: None, - }]; - let state = CollectionListState::new(collections.clone()); - - let lines = vec![ - "any_name".fg(colors.normal.white).into(), - "".fg(colors.bright.yellow).into(), - ]; - let expected = Paragraph::new(lines).block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(colors.primary.hover)), - ); - - let card = collection_list.build_card(&state, &collections[0], 0); - - assert_eq!(card, expected); - } - - #[test] - fn test_rendering() { - let colors = hac_colors::Colors::default(); - let collections = (0..100).map(|_| sample_collection()).collect::>(); - - let backend = TestBackend::new(80, 22); - let mut terminal = Terminal::new(backend).unwrap(); - let size = terminal.size().unwrap(); - let mut frame = terminal.get_frame(); - - let mut state = CollectionListState::new(collections.clone()); - let collection_list = CollectionList::new(&colors); - - for cell in &frame.buffer_mut().content { - assert_eq!(cell, &Cell::default()); - } - - collection_list.render(size, frame.buffer_mut(), &mut state); - - for cell in frame - .buffer_mut() - .content - .iter() - .skip(size.width.sub(1).into()) - .step_by(size.width.into()) - { - assert!(cell.symbol().ne(" ")); - } - } -} diff --git a/hac-client/src/pages/collection_dashboard/mod.rs b/hac-client/src/pages/collection_dashboard/mod.rs deleted file mode 100755 index b9e9817..0000000 --- a/hac-client/src/pages/collection_dashboard/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -#[allow(clippy::module_inception)] -mod collection_dashboard; -mod collection_list; -mod new_collection_form; - -pub use collection_dashboard::CollectionDashboard; diff --git a/hac-client/src/pages/collection_dashboard/new_collection_form.rs b/hac-client/src/pages/collection_dashboard/new_collection_form.rs deleted file mode 100755 index 2529772..0000000 --- a/hac-client/src/pages/collection_dashboard/new_collection_form.rs +++ /dev/null @@ -1,156 +0,0 @@ -use crate::pages::input::Input; - -use ratatui::buffer::Buffer; -use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect}; -use ratatui::style::{Style, Stylize}; -use ratatui::text::Line; -use ratatui::widgets::{ - Block, BorderType, Borders, Clear, Padding, Paragraph, StatefulWidget, Widget, -}; - -#[derive(Debug, Default, PartialEq, Eq)] -pub enum FormFocus { - #[default] - Name, - Description, - Confirm, - Cancel, -} - -struct FormLayout { - name_input: Rect, - desc_input: Rect, - confirm_button: Rect, - cancel_button: Rect, - hint: Rect, -} - -#[derive(Debug, Default)] -pub struct FormState { - pub name: String, - pub description: String, - pub focused_field: FormFocus, -} - -impl FormState { - pub fn reset(&mut self) { - self.name = String::default(); - self.description = String::default(); - self.focused_field = FormFocus::Name; - } -} - -#[derive(Debug)] -pub struct NewCollectionForm<'a> { - colors: &'a hac_colors::Colors, -} - -impl<'a> NewCollectionForm<'a> { - pub fn new(colors: &'a hac_colors::Colors) -> Self { - NewCollectionForm { colors } - } - - fn build_layout(&self, size: &Rect) -> FormLayout { - let size = Rect { - x: size.x + 2, - y: size.y + 1, - width: size.width.saturating_sub(4), - height: size.height.saturating_sub(2), - }; - let [name_input, desc_input, _, buttons, _, hint] = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(1), - Constraint::Length(3), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas(size); - - let [confirm_button, _, cancel_button] = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(10), - Constraint::Length(1), - Constraint::Length(10), - ]) - .flex(Flex::Center) - .areas(buttons); - - FormLayout { - name_input, - desc_input, - confirm_button, - cancel_button, - hint, - } - } -} - -impl StatefulWidget for NewCollectionForm<'_> { - type State = FormState; - - fn render(self, size: Rect, buf: &mut Buffer, state: &mut Self::State) { - let layout = self.build_layout(&size); - Clear.render(size, buf); - - let mut name_input = - Input::new(self.colors, "Name".into()).placeholder("My awesome API".into()); - - let mut desc_input = - Input::new(self.colors, "Description".into()).placeholder("Request testing".into()); - - match state.focused_field { - FormFocus::Name => name_input.focus(), - FormFocus::Description => desc_input.focus(), - _ => {} - }; - - let cancel_text = if state.focused_field.eq(&FormFocus::Cancel) { - "Cancel" - .fg(self.colors.normal.white) - .bg(self.colors.normal.red) - } else { - "Cancel".fg(self.colors.normal.white) - }; - - let cancel_button = Paragraph::new(Line::from(cancel_text).centered()).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(self.colors.bright.red)) - .border_type(BorderType::Rounded), - ); - - let confirm_text = if state.focused_field.eq(&FormFocus::Confirm) { - "Create" - .fg(self.colors.normal.white) - .bg(self.colors.normal.magenta) - } else { - "Create".fg(self.colors.normal.white) - }; - - let confirm_button = Paragraph::new(Line::from(confirm_text).centered()).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(self.colors.bright.magenta)) - .border_type(BorderType::Rounded), - ); - - let full_block = Block::default() - .padding(Padding::uniform(1)) - .style(Style::default().bg(self.colors.primary.background)); - - let hint = Paragraph::new("[Tab] to switch focus [Enter] to select a button") - .centered() - .fg(self.colors.normal.magenta); - - full_block.render(size, buf); - name_input.render(layout.name_input, buf, &mut state.name); - desc_input.render(layout.desc_input, buf, &mut state.description); - cancel_button.render(layout.cancel_button, buf); - confirm_button.render(layout.confirm_button, buf); - hint.render(layout.hint, buf); - } -} diff --git a/hac-client/src/pages/collection_list/create_collection.rs b/hac-client/src/pages/collection_list/create_collection.rs new file mode 100644 index 0000000..ed47d53 --- /dev/null +++ b/hac-client/src/pages/collection_list/create_collection.rs @@ -0,0 +1,109 @@ +use std::sync::mpsc::{channel, Sender}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use hac_core::command::Command; +use hac_core::text_object::cursor::Cursor; +use hac_core::text_object::{TextObject, Write}; +use ratatui::layout::Rect; +use ratatui::Frame; + +use super::form_shared::{ + build_form_layout, draw_form_layout, handle_form_key_event, set_form_cursor, FormEvent, FormLayout, +}; +use super::{CollectionListData, Routes}; +use crate::pages::overlay::make_overlay; +use crate::renderable::{Eventful, Renderable}; +use crate::router::{Navigate, RouterMessage}; +use crate::{HacColors, HacConfig}; + +#[derive(Debug)] +pub struct CreateCollection { + name: TextObject, + desc: TextObject, + size: Rect, + colors: HacColors, + cursor: Cursor, + config: HacConfig, + layout: FormLayout, + messager: Sender, +} + +impl CreateCollection { + pub fn new(size: Rect, config: HacConfig, colors: HacColors) -> Self { + Self { + config, + colors, + size, + layout: build_form_layout(size), + name: TextObject::::default(), + desc: TextObject::::default(), + cursor: Cursor::default(), + messager: channel().0, + } + } + + fn reset(&mut self) { + self.name = Default::default(); + self.desc = Default::default(); + self.cursor = Default::default(); + } +} + +impl Renderable for CreateCollection { + type Input = CollectionListData; + type Output = String; + + fn data(&self, _requester: u8) -> Self::Output { + self.name.to_string() + } + + fn draw(&mut self, frame: &mut Frame, _: Rect) -> anyhow::Result<()> { + make_overlay(self.colors.clone(), self.colors.normal.black, 0.2, frame); + draw_form_layout(self.layout, self.name.to_string(), &self.colors, frame); + set_form_cursor(self.layout, &self.cursor, frame); + Ok(()) + } + + fn resize(&mut self, new_size: Rect) { + self.size = new_size; + self.layout = build_form_layout(new_size); + } + + fn attach_navigator(&mut self, messager: Sender) { + self.messager = messager; + } +} + +impl Eventful for CreateCollection { + type Result = Command; + + fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { + if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { + return Ok(Some(Command::Quit)); + } + + match handle_form_key_event(key_event, &mut self.name, &mut self.cursor)? { + Some(FormEvent::Confirm) => { + hac_loader::collection_loader::create_collection(self.name.to_string(), &self.config)?; + self.messager + .send(RouterMessage::Navigate(Navigate::Back)) + .expect("failed to send navigate message"); + self.messager + .send(RouterMessage::DelDialog(Routes::CreateCollection.into())) + .expect("failed to send router message"); + } + Some(FormEvent::Cancel) => { + self.reset(); + self.messager + .send(RouterMessage::Navigate(Navigate::Back)) + .expect("failed to send navigate message"); + self.messager + .send(RouterMessage::DelDialog(Routes::CreateCollection.into())) + .expect("failed to send router message"); + } + _ => {} + } + + Ok(None) + } +} diff --git a/hac-client/src/pages/collection_list/delete_collection.rs b/hac-client/src/pages/collection_list/delete_collection.rs new file mode 100644 index 0000000..23b328a --- /dev/null +++ b/hac-client/src/pages/collection_list/delete_collection.rs @@ -0,0 +1,165 @@ +use std::sync::mpsc::{channel, Sender}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use hac_core::command::Command; +use hac_store::collection_meta::CollectionMeta; +use ratatui::layout::{Constraint, Flex, Layout, Margin, Rect}; +use ratatui::style::Stylize; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::Frame; + +use super::{CollectionListData, Routes}; +use crate::ascii::LOGO_ASCII; +use crate::pages::overlay::make_overlay; +use crate::renderable::{Eventful, Renderable}; +use crate::router::RouterMessage; +use crate::{router_drop_dialog, router_navigate_back, HacColors, HacConfig, MIN_HEIGHT, MIN_WIDTH}; + +#[derive(Debug)] +struct DeleteCollectionLayout { + logo: Rect, + left_button: Rect, + right_button: Rect, + name: Rect, +} + +#[derive(Debug)] +pub struct DeleteCollection { + layout: DeleteCollectionLayout, + colors: HacColors, + selected_idx: usize, + messager: Sender, + config: HacConfig, +} + +impl DeleteCollection { + pub fn new(size: Rect, colors: HacColors, config: HacConfig) -> Self { + Self { + config, + colors, + selected_idx: 0, + messager: channel().0, + layout: build_layout(size), + } + } +} + +impl Renderable for DeleteCollection { + type Input = CollectionListData; + type Output = String; + + fn draw(&mut self, frame: &mut Frame, _size: Rect) -> anyhow::Result<()> { + make_overlay(self.colors.clone(), self.colors.normal.black, 0.15, frame); + + let logo = Paragraph::new( + LOGO_ASCII + .iter() + .map(|line| Line::from(line.to_string()).fg(self.colors.bright.red).centered()) + .collect::>(), + ); + + let drawer = |meta: &CollectionMeta| { + let name = meta.name().to_string(); + let name = vec![ + Span::from("are you sure you want to delete "), + Span::from(name).bold().underlined(), + "?".into(), + ]; + let name = Paragraph::new(Line::from(name).fg(self.colors.normal.red).centered()).wrap(Wrap { trim: true }); + + let left_button = Paragraph::new(Line::from("[ENTER] CONFIRM").centered().fg(self.colors.normal.black)) + .block(Block::default().borders(Borders::ALL).fg(self.colors.bright.red)) + .bg(self.colors.bright.red) + .bold(); + + let right_button = Paragraph::new(Line::from("[ESC] CANCEL").centered()) + .block(Block::default().borders(Borders::ALL).fg(self.colors.normal.blue)) + .fg(self.colors.bright.red) + .bg(self.colors.normal.blue) + .bold(); + + frame.render_widget(logo, self.layout.logo); + frame.render_widget(name, self.layout.name); + frame.render_widget(left_button, self.layout.left_button); + frame.render_widget(right_button, self.layout.right_button); + }; + + hac_store::collection_meta::get_collection_meta(self.selected_idx, drawer); + + Ok(()) + } + + fn data(&self, _requester: u8) -> Self::Output { + let name = hac_store::collection_meta::get_collection_meta(self.selected_idx, |meta| meta.name().to_string()); + name + } + + fn update(&mut self, data: Self::Input) { + if let CollectionListData::DeleteCollection(selected_idx) = data { + self.selected_idx = selected_idx; + } + } + + fn attach_navigator(&mut self, messager: Sender) { + self.messager = messager; + } +} + +impl Eventful for DeleteCollection { + type Result = Command; + + fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { + if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { + return Ok(Some(Command::Quit)); + } + + match key_event.code { + KeyCode::Esc => { + router_navigate_back!(&self.messager); + router_drop_dialog!(&self.messager, Routes::DeleteCollection.into()); + } + KeyCode::Char('o') | KeyCode::Char('y') | KeyCode::Enter => { + let name = + hac_store::collection_meta::get_collection_meta(self.selected_idx, |meta| meta.name().to_string()); + hac_loader::collection_loader::delete_collection(name, &self.config)?; + router_navigate_back!(&self.messager); + router_drop_dialog!(&self.messager, Routes::DeleteCollection.into()); + } + _ => {} + } + + Ok(None) + } +} + +fn build_layout(total_size: Rect) -> DeleteCollectionLayout { + let [_, form, _] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(MIN_WIDTH), Constraint::Fill(1)]) + .flex(Flex::Center) + .areas(total_size); + + let [_, form, _] = Layout::vertical([Constraint::Fill(1), Constraint::Length(MIN_HEIGHT), Constraint::Fill(1)]) + .flex(Flex::End) + .areas(form); + + let form = form.inner(&Margin::new(2, 0)); + let [logo, _, name, _, buttons] = Layout::vertical([ + Constraint::Length(LOGO_ASCII.len() as u16), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(3), + ]) + .flex(Flex::Center) + .areas(form); + + let [left_button, _, right_button] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(1), Constraint::Fill(1)]).areas(buttons); + + DeleteCollectionLayout { + name, + logo, + left_button, + right_button, + } +} diff --git a/hac-client/src/pages/collection_list/edit_collection.rs b/hac-client/src/pages/collection_list/edit_collection.rs new file mode 100644 index 0000000..9a85bac --- /dev/null +++ b/hac-client/src/pages/collection_list/edit_collection.rs @@ -0,0 +1,118 @@ +use std::sync::mpsc::{channel, Sender}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use hac_core::command::Command; +use hac_core::text_object::cursor::Cursor; +use hac_core::text_object::{TextObject, Write}; +use ratatui::layout::Rect; +use ratatui::Frame; + +use super::form_shared::{ + build_form_layout, draw_form_layout, handle_form_key_event, set_form_cursor, FormEvent, FormLayout, +}; +use super::{CollectionListData, Routes}; +use crate::pages::overlay::make_overlay; +use crate::renderable::{Eventful, Renderable}; +use crate::router::{Navigate, RouterMessage}; +use crate::{HacColors, HacConfig}; + +#[derive(Debug)] +pub struct EditCollection { + name: TextObject, + collection_idx: usize, + size: Rect, + colors: HacColors, + cursor: Cursor, + config: HacConfig, + layout: FormLayout, + messager: Sender, +} + +impl EditCollection { + pub fn new(size: Rect, config: HacConfig, colors: HacColors) -> Self { + Self { + config, + colors, + size, + layout: build_form_layout(size), + name: TextObject::::default(), + cursor: Cursor::default(), + collection_idx: 0, + messager: channel().0, + } + } +} + +impl Renderable for EditCollection { + type Input = CollectionListData; + type Output = String; + + fn data(&self, _requester: u8) -> Self::Output { + tracing::debug!("{:?}", self.name); + self.name.to_string() + } + + fn draw(&mut self, frame: &mut Frame, _: Rect) -> anyhow::Result<()> { + make_overlay(self.colors.clone(), self.colors.normal.black, 0.2, frame); + draw_form_layout(self.layout, self.name.to_string(), &self.colors, frame); + set_form_cursor(self.layout, &self.cursor, frame); + Ok(()) + } + + fn update(&mut self, data: Self::Input) { + if let CollectionListData::EditCollection(idx) = data { + tracing::debug!("new update {data:?}"); + self.collection_idx = idx; + self.name = + hac_store::collection_meta::get_collection_meta(idx, |meta| TextObject::from(meta.name()).with_write()); + tracing::debug!("{:?}", self.name); + self.cursor.move_to_col(self.name.line_len(0) + 1); + } + } + + fn resize(&mut self, new_size: Rect) { + self.size = new_size; + self.layout = build_form_layout(new_size); + } + + fn attach_navigator(&mut self, messager: Sender) { + self.messager = messager; + } +} + +impl Eventful for EditCollection { + type Result = Command; + + fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { + if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { + return Ok(Some(Command::Quit)); + } + + match handle_form_key_event(key_event, &mut self.name, &mut self.cursor)? { + Some(FormEvent::Confirm) => { + hac_loader::collection_loader::edit_collection( + self.name.to_string(), + self.collection_idx, + &self.config, + )?; + self.messager + .send(RouterMessage::Navigate(Navigate::Back)) + .expect("failed to send navigate message"); + self.messager + .send(RouterMessage::DelDialog(Routes::EditCollection.into())) + .expect("failed to send router message"); + } + Some(FormEvent::Cancel) => { + self.messager + .send(RouterMessage::Navigate(Navigate::Back)) + .expect("failed to send navigate message"); + self.messager + .send(RouterMessage::DelDialog(Routes::EditCollection.into())) + .expect("failed to send router message"); + } + _ => {} + } + + Ok(None) + } +} diff --git a/hac-client/src/pages/collection_list/form_shared.rs b/hac-client/src/pages/collection_list/form_shared.rs new file mode 100644 index 0000000..1f576d3 --- /dev/null +++ b/hac-client/src/pages/collection_list/form_shared.rs @@ -0,0 +1,131 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use hac_core::text_object::cursor::Cursor; +use hac_core::text_object::{TextObject, Write}; +use ratatui::layout::{Constraint, Flex, Layout, Margin, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::ascii::LOGO_ASCII; +use crate::components::input::Input; +use crate::{HacColors, MIN_HEIGHT, MIN_WIDTH}; + +#[derive(Debug, Clone, Copy)] +pub struct FormLayout { + pub name_input: Rect, + pub hint: Rect, + pub logo: Rect, +} + +#[derive(Debug)] +pub enum FormEvent { + Confirm, + Cancel, +} + +pub fn build_form_layout(area: Rect) -> FormLayout { + let [_, form, _] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(MIN_WIDTH), Constraint::Fill(1)]) + .flex(Flex::Center) + .areas(area); + + let [_, form, _] = Layout::vertical([Constraint::Fill(1), Constraint::Length(MIN_HEIGHT), Constraint::Fill(1)]) + .flex(Flex::End) + .areas(form); + + let form = form.inner(&Margin::new(2, 0)); + + let [logo, _, name_input, _, hint] = Layout::vertical([ + Constraint::Length(LOGO_ASCII.len() as u16), + Constraint::Length(1), + Constraint::Length(4), + Constraint::Length(1), + Constraint::Length(1), + ]) + .flex(Flex::Center) + .areas(form); + + FormLayout { name_input, hint, logo } +} + +pub fn handle_form_key_event( + key_event: KeyEvent, + text_object: &mut TextObject, + cursor: &mut Cursor, +) -> anyhow::Result> { + match key_event.code { + KeyCode::Enter => return Ok(Some(FormEvent::Confirm)), + KeyCode::Esc => return Ok(Some(FormEvent::Cancel)), + KeyCode::Char('b') if matches!(key_event.modifiers, KeyModifiers::CONTROL) => cursor.move_left(1), + KeyCode::Left if matches!(key_event.modifiers, KeyModifiers::ALT) => { + let (col, row) = text_object.find_char_before_whitespace(cursor); + cursor.move_to(col, row); + } + KeyCode::Right if matches!(key_event.modifiers, KeyModifiers::ALT) => { + let (col, row) = text_object.find_char_after_whitespace(cursor); + cursor.move_to(col, row); + } + KeyCode::Left => cursor.move_left(1), + KeyCode::Char('e') if matches!(key_event.modifiers, KeyModifiers::CONTROL) => { + cursor.move_to_line_end(text_object.line_len(0)) + } + KeyCode::Down => cursor.move_to_line_end(text_object.line_len(0)), + KeyCode::Char('a') if matches!(key_event.modifiers, KeyModifiers::CONTROL) => cursor.move_to_line_start(), + KeyCode::Up => cursor.move_to_line_start(), + KeyCode::Char('f') if matches!(key_event.modifiers, KeyModifiers::CONTROL) => cursor.move_right(1), + KeyCode::Right => { + cursor.move_right(1); + cursor.maybe_snap_to_col(text_object.line_len(0)); + } + KeyCode::Char('d') if matches!(key_event.modifiers, KeyModifiers::CONTROL) => { + text_object.erase_current_char(cursor) + } + KeyCode::Char('u') if matches!(key_event.modifiers, KeyModifiers::CONTROL) => { + text_object.clear_line(cursor); + cursor.move_to_line_start(); + } + KeyCode::Backspace => { + text_object.erase_previous_char(cursor); + cursor.move_left(1); + } + KeyCode::Char(c) => { + text_object.insert_char(c, cursor); + cursor.move_right(1); + } + _ => {} + } + + Ok(None) +} + +pub fn draw_form_layout(layout: FormLayout, name: String, colors: &HacColors, frame: &mut Frame) { + let label = String::from("Collection Name"); + let name_input = Input::new(Some(&name), Some(&label), colors.clone()) + .value_style(Style::default().fg(colors.normal.white)) + .label_style(Style::default().fg(colors.bright.black)); + + let hint = vec![ + "Enter".fg(colors.bright.green).bold(), + " - Confirm • ".fg(colors.bright.black), + "Esc".fg(colors.bright.green).bold(), + " - Cancel".fg(colors.bright.black), + ]; + + let logo = Paragraph::new( + LOGO_ASCII + .iter() + .map(|line| Line::from(line.to_string()).fg(colors.bright.red).centered()) + .collect::>(), + ); + + frame.render_widget(logo, layout.logo); + frame.render_widget(name_input, layout.name_input); + frame.render_widget(Line::from(hint).centered(), layout.hint); +} + +pub fn set_form_cursor(layout: FormLayout, cursor: &Cursor, frame: &mut Frame) { + frame.set_cursor( + layout.name_input.x + 1 + cursor.col() as u16, + layout.name_input.y + 2 + cursor.row() as u16, + ); +} diff --git a/hac-client/src/pages/collection_list/mod.rs b/hac-client/src/pages/collection_list/mod.rs new file mode 100644 index 0000000..12b0dd4 --- /dev/null +++ b/hac-client/src/pages/collection_list/mod.rs @@ -0,0 +1,416 @@ +mod create_collection; +mod delete_collection; +mod edit_collection; +mod form_shared; + +use std::fmt::Debug; +use std::sync::mpsc::{channel, Sender}; + +use create_collection::CreateCollection; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use delete_collection::DeleteCollection; +use edit_collection::EditCollection; +use hac_core::command::Command; +use hac_store::collection_meta::{CollectionMeta, CollectionMetaSorting, ReadableByteSize}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Clear, Paragraph}; +use ratatui::Frame; + +use crate::app::Routes; +use crate::components::list_item::ListItem; +use crate::pages::collection_viewer::CollectionViewer; +use crate::renderable::{Eventful, Renderable}; +use crate::router::{Navigate, RouterMessage}; +use crate::{HacColors, HacConfig}; + +#[derive(Debug, PartialEq)] +struct DashboardLayout { + collections_pane: Rect, + hint_pane: Rect, + title_pane: Rect, + total_size: Rect, +} + +#[derive(Debug)] +pub struct CollectionList { + colors: HacColors, + sorting_kind: CollectionMetaSorting, + selected: usize, + config: HacConfig, + scroll: usize, + messager: Sender, + layout: DashboardLayout, + pub command_sender: Option>, + extended_hint: bool, +} + +impl CollectionList { + const LIST_ITEM_HEIGHT: u16 = 3; + + pub fn new(size: Rect, config: HacConfig, colors: HacColors) -> Self { + let mut list = Self { + colors, + config, + selected: 0, + scroll: 0, + sorting_kind: CollectionMetaSorting::default(), + messager: channel().0, + layout: build_layout(size, false), + command_sender: None, + extended_hint: false, + }; + list.sort_list(); + list + } + + fn sort_list(&mut self) { + hac_store::collection_meta::sort_collection_meta(self.sorting_kind); + } + + fn draw_title(&self, frame: &mut Frame) -> anyhow::Result<()> { + let selected_style = |kind: CollectionMetaSorting| { + if kind == self.sorting_kind { + self.colors.bright.blue + } else { + self.colors.bright.black + } + }; + + let title = " HAC ".bg(self.colors.normal.red).fg(self.colors.normal.white); + let sorting = vec![ + "Most recent".fg(selected_style(CollectionMetaSorting::Recent)), + " ❘ ".fg(self.colors.bright.black), + "By name".fg(selected_style(CollectionMetaSorting::Name)), + " ❘ ".fg(self.colors.bright.black), + "By size".fg(selected_style(CollectionMetaSorting::Size)), + ]; + + let lines = vec![Line::from(title), "".into(), Line::from(sorting)]; + frame.render_widget(Paragraph::new(lines), self.layout.title_pane); + + Ok(()) + } + + fn max_items_onscreen(&self) -> usize { + (self.layout.collections_pane.height / Self::LIST_ITEM_HEIGHT).into() + } + + fn draw_collection_list(&mut self, frame: &mut Frame) -> anyhow::Result<()> { + let layout = self.layout.collections_pane; + let max_items = self.max_items_onscreen(); + + let drawer = |collections_meta: &[CollectionMeta]| { + for (ref mut idx, (i, item)) in collections_meta + .iter() + .enumerate() + .skip(self.scroll) + .take(max_items) + .enumerate() + { + let selected = i == self.selected; + let modified = item.modified(); + let size = item.size().readable_byte_size(); + let description = format!("{modified} - {size}"); + + let item = if selected { + ListItem::new(item.name(), Some(&description), self.colors.clone()) + .title_style(Style::new().fg(self.colors.normal.red)) + .desc_style(Style::new().fg(self.colors.bright.black)) + .select() + } else { + ListItem::new(item.name(), Some(&description), self.colors.clone()) + .title_style(Style::new().fg(self.colors.normal.white)) + .desc_style(Style::new().fg(self.colors.bright.black)) + }; + let size = Rect::new(layout.x, layout.y + (*idx as u16 * 3), layout.width, 2); + frame.render_widget(item, size); + + *idx += 1; + } + }; + + hac_store::collection_meta::collections_meta(drawer); + + Ok(()) + } + + fn draw_hint_text(&self, frame: &mut Frame) { + if self.extended_hint { + let lines = vec![ + Line::from(vec![ + "j/k ↑/↓".fg(self.colors.normal.green), + " - choose • ".fg(self.colors.bright.black), + "n".fg(self.colors.normal.green), + " - new • ".fg(self.colors.bright.black), + "enter".fg(self.colors.normal.green), + " - select".fg(self.colors.bright.black), + ]), + Line::from(vec![ + "?".fg(self.colors.normal.green), + " - show more • ".fg(self.colors.bright.black), + "ctrl c".fg(self.colors.normal.green), + " - quit • ".fg(self.colors.bright.black), + "d".fg(self.colors.normal.green), + " - delete".fg(self.colors.bright.black), + ]), + Line::from(vec![ + "r".fg(self.colors.normal.green), + " - refresh • ".fg(self.colors.bright.black), + "tab".fg(self.colors.normal.green), + " - change sorting".fg(self.colors.bright.black), + ]), + ]; + frame.render_widget(Paragraph::new(lines), self.layout.hint_pane); + } else { + let hint = vec![ + "j/k ↑/↓".fg(self.colors.normal.green), + " - Choose • ".fg(self.colors.bright.black), + "n".fg(self.colors.normal.green), + " - New • ".fg(self.colors.bright.black), + "Enter".fg(self.colors.normal.green), + " - Select • ".fg(self.colors.bright.black), + "?".fg(self.colors.normal.green), + " - Show more • ".fg(self.colors.bright.black), + "Ctrl c".fg(self.colors.normal.green), + " - Quit".fg(self.colors.bright.black), + ]; + frame.render_widget(Line::from(hint), self.layout.hint_pane); + }; + } + + fn draw_background(&self, size: Rect, frame: &mut Frame) { + frame.render_widget(Clear, size); + frame.render_widget(Block::default().bg(self.colors.primary.background), size); + } + + fn maybe_scroll_list(&mut self) { + if self.scroll > self.selected { + let offset = self.scroll - self.selected; + self.scroll -= offset; + return; + } + + let max_items = self.max_items_onscreen() - 1; + let normalized = self.selected - self.scroll; + if normalized >= max_items { + let offset = self.selected - max_items; + self.scroll = offset; + } + } +} + +#[derive(Debug)] +pub enum CollectionListData { + CreateCollection, + EditCollection(usize), + DeleteCollection(usize), + CollectionViewer, +} + +impl Renderable for CollectionList { + type Input = String; + type Output = CollectionListData; + + fn data(&self, requester: u8) -> Self::Output { + match Routes::try_from(requester) { + Ok(Routes::CreateCollection) => CollectionListData::CreateCollection, + Ok(Routes::EditCollection) => CollectionListData::EditCollection(self.selected), + Ok(Routes::DeleteCollection) => CollectionListData::DeleteCollection(self.selected), + Ok(Routes::CollectionViewer) => CollectionListData::DeleteCollection(self.selected), + _ => unreachable!(), + } + } + + fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()> { + self.draw_background(size, frame); + self.draw_title(frame)?; + self.draw_collection_list(frame)?; + self.draw_hint_text(frame); + + Ok(()) + } + + fn tick(&mut self) -> anyhow::Result<()> { + if hac_loader::collection_loader::has_changes() { + hac_loader::collection_loader::get_collections_metadata()?; + self.sort_list(); + } + Ok(()) + } + + fn attach_navigator(&mut self, messager: Sender) { + self.messager = messager; + } + + fn register_command_handler(&mut self, sender: Sender) { + self.command_sender = Some(sender.clone()); + } + + fn resize(&mut self, new_size: Rect) { + self.layout = build_layout(new_size, self.extended_hint); + } + + fn update(&mut self, data: Self::Input) { + self.sort_list(); + hac_store::collection_meta::collections_meta(|collections_meta| { + self.selected = collections_meta + .iter() + .position(|col| col.path().to_string_lossy().contains(&data)) + .expect("collection to select doesn't exist"); + }) + } +} + +impl Eventful for CollectionList { + type Result = Command; + + fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { + if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { + return Ok(Some(Command::Quit)); + }; + + match key_event.code { + KeyCode::Char('?') => { + self.extended_hint = !self.extended_hint; + self.layout = build_layout(self.layout.total_size, self.extended_hint); + } + KeyCode::Char('j') | KeyCode::Down => hac_store::collection_meta::collections_meta(|collections_meta| { + self.selected = usize::min(self.selected + 1, collections_meta.len() - 1); + self.maybe_scroll_list(); + }), + KeyCode::Char('k') | KeyCode::Up => { + self.selected = self.selected.saturating_sub(1); + self.maybe_scroll_list(); + } + KeyCode::PageDown => hac_store::collection_meta::collections_meta(|collections_meta| { + let half = self.layout.collections_pane.height / 2; + self.selected = usize::min(self.selected + half as usize, collections_meta.len() - 1); + self.maybe_scroll_list(); + }), + KeyCode::PageUp => { + let half = self.layout.collections_pane.height / 2; + self.selected = self.selected.saturating_sub(half.into()); + self.maybe_scroll_list(); + } + KeyCode::Char('r') => { + hac_loader::collection_loader::get_collections_metadata()?; + self.sort_list(); + } + KeyCode::Char('d') if matches!(key_event.modifiers, KeyModifiers::CONTROL) => { + hac_store::collection_meta::collections_meta(|collections_meta| { + let half = self.layout.collections_pane.height / 2; + self.selected = usize::min(self.selected + half as usize, collections_meta.len() - 1); + self.maybe_scroll_list(); + }); + } + KeyCode::Char('d') => { + let delete_form = + DeleteCollection::new(self.layout.total_size, self.colors.clone(), self.config.clone()); + let message = RouterMessage::AddDialog(Routes::DeleteCollection.into(), Box::new(delete_form)); + self.messager.send(message).expect("failed to create new route"); + self.messager + .send(RouterMessage::Navigate(Navigate::To(Routes::DeleteCollection.into()))) + .expect("failed to send navigation message"); + } + KeyCode::Char('u') if matches!(key_event.modifiers, KeyModifiers::CONTROL) => { + self.selected = 0; + self.maybe_scroll_list(); + } + KeyCode::Char('G') => hac_store::collection_meta::collections_meta(|collections_meta| { + self.selected = collections_meta.len() - 1; + self.maybe_scroll_list(); + }), + KeyCode::Char('g') => { + let half = self.layout.collections_pane.height / 2; + self.selected = self.selected.saturating_sub(half.into()); + self.maybe_scroll_list(); + } + KeyCode::Tab => { + self.sorting_kind = self.sorting_kind.next(); + self.sort_list(); + } + KeyCode::BackTab => { + self.sorting_kind = self.sorting_kind.prev(); + self.sort_list(); + } + KeyCode::Char('n') => { + let create_form = + CreateCollection::new(self.layout.total_size, self.config.clone(), self.colors.clone()); + let message = RouterMessage::AddDialog(Routes::CreateCollection.into(), Box::new(create_form)); + self.messager.send(message).expect("failed to create new route"); + self.messager + .send(RouterMessage::Navigate(Navigate::To(Routes::CreateCollection.into()))) + .expect("failed to send navigation message"); + } + KeyCode::Char('e') => { + let edit_form = EditCollection::new(self.layout.total_size, self.config.clone(), self.colors.clone()); + let message = RouterMessage::AddDialog(Routes::EditCollection.into(), Box::new(edit_form)); + self.messager.send(message).expect("failed to create new route"); + self.messager + .send(RouterMessage::Navigate(Navigate::To(Routes::EditCollection.into()))) + .expect("failed to send navigation message"); + } + KeyCode::Enter => { + let Some(path) = hac_store::collection_meta::collections_meta(|collections_meta| { + if collections_meta.is_empty() { + return None; + } + assert!(collections_meta.len() > self.selected); + let selected = &collections_meta[self.selected]; + Some(selected.path().clone()) + }) else { + return Ok(None); + }; + let collection_viewer = + CollectionViewer::new(self.layout.total_size, self.colors.clone(), self.config.clone()); + let collection = match hac_loader::collection_loader::load_collection(path, &self.config) { + Ok(collection) => collection, + Err(_) => todo!(), + }; + hac_store::collection::set_collection(Some(collection)); + self.messager + .send(RouterMessage::AddRoute( + Routes::CollectionViewer.into(), + Box::new(collection_viewer), + )) + .expect("failed to send router message"); + self.messager + .send(RouterMessage::Navigate(Navigate::To(Routes::CollectionViewer.into()))) + .expect("failed to send navigation message"); + } + _ => {} + } + + Ok(None) + } +} + +fn build_layout(total_size: Rect, extended_hint: bool) -> DashboardLayout { + let size = Rect::new(total_size.x + 1, total_size.y, total_size.width - 1, total_size.height); + let [top, help_pane] = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(if extended_hint { 4 } else { 2 }), + ]) + .areas(size); + + let [_, title_pane, _, collections_pane] = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .areas(top); + + DashboardLayout { + collections_pane, + hint_pane: help_pane, + title_pane, + total_size, + } +} diff --git a/hac-client/src/pages/collection_viewer/collection_store.rs b/hac-client/src/pages/collection_viewer/collection_store.rs deleted file mode 100755 index 551fb29..0000000 --- a/hac-client/src/pages/collection_viewer/collection_store.rs +++ /dev/null @@ -1,559 +0,0 @@ -use hac_core::collection::types::{Request, RequestKind}; -use hac_core::collection::Collection; - -use crate::pages::collection_viewer::collection_viewer::CollectionViewerOverlay; -use crate::pages::collection_viewer::collection_viewer::PaneFocus; - -use std::cell::RefCell; -use std::collections::HashMap; -use std::rc::Rc; -use std::sync::{Arc, RwLock}; - -#[derive(Debug)] -pub struct CollectionState { - collection: Rc>, - hovered_request: Option, - selected_request: Option>>, - dirs_expanded: Rc>>, - selected_pane: Option, - focused_pane: PaneFocus, - has_pending_request: bool, - overlay_stack: Vec, -} - -#[derive(Debug, Default)] -pub struct CollectionStore { - state: Option>>, -} - -#[derive(Debug)] -pub enum CollectionStoreAction { - SetSelectedRequest(Option>>), - SetHoveredRequest(Option), - InsertRequest(RequestKind), - HoverPrev, - HoverNext, - ToggleDirectory(String), - SetFocusedPane(PaneFocus), - SetSelectedPane(Option), - SetPendingRequest(bool), -} - -impl CollectionStore { - pub fn set_state(&mut self, collection: Collection) { - let selected_request = collection.requests.as_ref().and_then(|requests| { - requests.read().unwrap().first().and_then(|req| { - if let RequestKind::Single(req) = req { - Some(req.clone()) - } else { - None - } - }) - }); - - let hovered_request = collection - .requests - .as_ref() - .and_then(|items| items.read().unwrap().first().map(|item| item.get_id())); - - let state = CollectionState { - selected_request, - hovered_request, - dirs_expanded: Rc::new(RefCell::new(HashMap::default())), - collection: Rc::new(RefCell::new(collection)), - focused_pane: PaneFocus::Sidebar, - selected_pane: None, - has_pending_request: false, - overlay_stack: vec![], - }; - - self.state = Some(Rc::new(RefCell::new(state))); - } - - pub fn dispatch(&mut self, action: CollectionStoreAction) { - if let Some(ref state) = self.state { - match action { - CollectionStoreAction::SetSelectedRequest(maybe_req) => { - state.borrow_mut().selected_request = maybe_req - } - CollectionStoreAction::SetHoveredRequest(maybe_req_id) => { - state.borrow_mut().hovered_request = maybe_req_id - } - CollectionStoreAction::InsertRequest(request_kind) => { - state - .borrow_mut() - .collection - .borrow_mut() - .requests - .get_or_insert_with(|| Arc::new(RwLock::new(vec![]))) - .write() - .unwrap() - .push(request_kind); - } - CollectionStoreAction::HoverPrev => self.maybe_hover_prev(), - CollectionStoreAction::HoverNext => self.maybe_hover_next(), - CollectionStoreAction::ToggleDirectory(dir_id) => { - let state = state.borrow_mut(); - let mut dirs = state.dirs_expanded.borrow_mut(); - let entry = dirs.entry(dir_id).or_insert(false); - *entry = !*entry; - } - CollectionStoreAction::SetFocusedPane(pane) => { - state.borrow_mut().focused_pane = pane - } - CollectionStoreAction::SetSelectedPane(pane) => { - state.borrow_mut().selected_pane = pane - } - CollectionStoreAction::SetPendingRequest(is_pending) => { - state.borrow_mut().has_pending_request = is_pending; - } - } - } - } - - pub fn get_selected_request(&self) -> Option>> { - self.state - .as_ref() - .and_then(|state| state.borrow().selected_request.clone()) - } - - pub fn get_focused_pane(&self) -> PaneFocus { - self.state - .as_ref() - .map(|state| state.borrow().focused_pane) - .expect("tried to get the focused pane without a state") - } - - pub fn get_selected_pane(&self) -> Option { - self.state - .as_ref() - .map(|state| state.borrow().selected_pane) - .expect("tried to get the selected pane without a state") - } - - pub fn get_hovered_request(&self) -> Option { - self.state - .as_ref() - .and_then(|state| state.borrow().hovered_request.clone()) - } - - pub fn get_collection(&self) -> Option>> { - self.state - .as_ref() - .map(|state| state.borrow().collection.clone()) - } - - pub fn get_dirs_expanded(&mut self) -> Option>>> { - self.state - .as_mut() - .map(|state| state.borrow().dirs_expanded.clone()) - } - - pub fn push_overlay(&mut self, overlay: CollectionViewerOverlay) { - if let Some(state) = self.state.as_mut() { - state.borrow_mut().overlay_stack.push(overlay) - } - } - - pub fn pop_overlay(&mut self) -> Option { - self.state - .as_mut() - .and_then(|state| state.borrow_mut().overlay_stack.pop()) - } - - pub fn peek_overlay(&self) -> CollectionViewerOverlay { - self.state - .as_ref() - .and_then(|state| state.borrow().overlay_stack.last().cloned()) - .unwrap_or(CollectionViewerOverlay::None) - } - - pub fn has_overlay(&self) -> bool { - self.state - .as_ref() - .map(|state| !state.borrow().overlay_stack.is_empty()) - .unwrap_or(false) - } - - pub fn clear_overlay(&mut self) { - if let Some(state) = self.state.as_mut() { - state.borrow_mut().overlay_stack.clear() - } - } - - pub fn get_requests(&self) -> Option>>> { - self.state.as_ref().and_then(|state| { - state - .borrow() - .collection - .borrow() - .requests - .as_ref() - .cloned() - }) - } - - pub fn has_pending_request(&self) -> bool { - self.state - .as_ref() - .is_some_and(|state| state.borrow().has_pending_request) - } - - fn maybe_hover_prev(&mut self) { - if self.get_requests().is_some() { - let requests = self.get_requests().unwrap(); - - let Some(id) = self.get_hovered_request() else { - tracing::debug!("{:?}", self.get_hovered_request()); - self.dispatch(CollectionStoreAction::SetHoveredRequest( - requests.read().unwrap().first().map(|req| req.get_id()), - )); - tracing::debug!("{:?}", self.get_hovered_request()); - return; - }; - - if let Some(next) = find_next_entry( - &requests.read().unwrap(), - VisitNode::Prev, - &self.get_dirs_expanded().unwrap().borrow(), - &id, - ) { - self.dispatch(CollectionStoreAction::SetHoveredRequest(Some( - next.get_id(), - ))); - }; - } - } - - fn maybe_hover_next(&mut self) { - if self.get_requests().is_some() { - let requests = self.get_requests().unwrap(); - - let Some(id) = self.get_hovered_request() else { - self.dispatch(CollectionStoreAction::SetHoveredRequest( - requests.read().unwrap().first().map(|req| req.get_id()), - )); - return; - }; - - if let Some(next) = find_next_entry( - &requests.read().unwrap(), - VisitNode::Next, - &self.get_dirs_expanded().unwrap().borrow(), - &id, - ) { - self.dispatch(CollectionStoreAction::SetHoveredRequest(Some( - next.get_id(), - ))); - }; - }; - } - - pub fn find_hovered_request(&mut self) -> RequestKind { - get_request_by_id( - &self.get_requests().as_ref().unwrap().read().unwrap(), - &self.get_dirs_expanded().unwrap().borrow(), - self.get_hovered_request().as_ref().unwrap(), - ) - } - - pub fn remove_item(&mut self, item_id: String) { - if let Some(request) = self.get_selected_request() { - if request.read().unwrap().id.eq(&item_id) { - self.dispatch(CollectionStoreAction::SetSelectedRequest(None)); - } - } - let mut requests = self.get_requests(); - let mut requests = requests.as_mut().unwrap().write().unwrap(); - requests.retain(|req| req.get_id().ne(&item_id)); - requests.iter_mut().for_each(|req| { - if let RequestKind::Nested(dir) = req { - dir.requests - .write() - .unwrap() - .retain(|child| child.get_id().ne(&item_id)); - } - }); - self.dispatch(CollectionStoreAction::SetHoveredRequest( - requests.first().map(|req| req.get_id()), - )); - } -} - -#[derive(PartialEq)] -enum VisitNode { - Next, - Prev, - Curr, -} - -fn traverse( - found: &mut bool, - visit: &VisitNode, - dirs_expanded: &HashMap, - current: &RequestKind, - needle: &str, - path: &mut Vec, -) -> bool { - let node_match = current.get_id().eq(needle); - - match (&visit, node_match, &found) { - // We are looking for the next item and we already found the current one (needle), so the - // current item must be the next... we add it to the path and return found = true - (VisitNode::Next, false, true) => { - path.push(current.clone()); - return *found; - } - // We are looking for the previous item and we just found the current one (needle), so we - // simply return found = true as we dont want the current one on the path - (VisitNode::Prev, true, false) => { - *found = true; - return *found; - } - // We are looking for the next and just found the current one, so we set the flag to - // true in order to know when to return the next. - (VisitNode::Next, true, false) => *found = true, - // we are looking for the current node and we found it, so we set the flag to true - // and return immediatly - (VisitNode::Curr, true, _) => { - path.push(current.clone()); - *found = true; - return *found; - } - _ => {} - } - - // visit the node in order to have the full traversed path... - path.push(current.clone()); - - if let RequestKind::Nested(dir) = current { - // if we are on a collapsed directory we should not recurse into its children - if !dirs_expanded.get(&dir.id).unwrap() { - return false; - } - - // recurse into children when expanded - for node in dir.requests.read().unwrap().iter() { - if traverse(found, visit, dirs_expanded, node, needle, path) { - return true; - } - } - } - - false -} - -fn get_request_by_id( - tree: &[RequestKind], - dirs_expanded: &HashMap, - id: &str, -) -> RequestKind { - let mut found = false; - let mut path = vec![]; - - for node in tree { - if traverse( - &mut found, - &VisitNode::Curr, - dirs_expanded, - node, - id, - &mut path, - ) { - break; - } - } - - path.pop() - .expect("attempting to find an unexisting request") -} - -fn find_next_entry( - tree: &[RequestKind], - visit: VisitNode, - dirs_expanded: &HashMap, - needle: &str, -) -> Option { - let mut found = false; - let mut path = vec![]; - - for node in tree { - if traverse(&mut found, &visit, dirs_expanded, node, needle, &mut path) { - break; - } - } - - found.then(|| path.pop()).flatten() -} - -#[cfg(test)] -mod tests { - use super::*; - use hac_core::collection::types::{Directory, Request, RequestMethod}; - use std::collections::HashMap; - - fn create_root_one() -> RequestKind { - RequestKind::Single(Arc::new(RwLock::new(Request { - id: "root".to_string(), - method: RequestMethod::Get, - name: "Root1".to_string(), - auth_method: None, - parent: None, - headers: None, - uri: "/root1".to_string(), - body_type: None, - body: None, - }))) - } - - fn create_child_one() -> RequestKind { - RequestKind::Single(Arc::new(RwLock::new(Request { - id: "child_one".to_string(), - auth_method: None, - parent: Some(String::from("dir")), - method: RequestMethod::Post, - name: "Child1".to_string(), - uri: "/nested1/child1".to_string(), - headers: None, - body_type: None, - body: None, - }))) - } - - fn create_child_two() -> RequestKind { - RequestKind::Single(Arc::new(RwLock::new(Request { - id: "child_two".to_string(), - method: RequestMethod::Put, - auth_method: None, - name: "Child2".to_string(), - headers: None, - parent: Some(String::from("dir")), - uri: "/nested1/child2".to_string(), - body_type: None, - body: None, - }))) - } - - fn create_not_used() -> RequestKind { - RequestKind::Single(Arc::new(RwLock::new(Request { - id: "not_used".to_string(), - method: RequestMethod::Put, - name: "NotUsed".to_string(), - parent: None, - auth_method: None, - headers: None, - uri: "/not/used".to_string(), - body_type: None, - body: None, - }))) - } - - fn create_dir() -> Directory { - Directory { - id: "dir".to_string(), - name: "Nested1".to_string(), - requests: Arc::new(RwLock::new(vec![create_child_one(), create_child_two()])), - } - } - - fn create_nested() -> RequestKind { - RequestKind::Nested(create_dir()) - } - - fn create_root_two() -> RequestKind { - RequestKind::Single(Arc::new(RwLock::new(Request { - id: "root_two".to_string(), - method: RequestMethod::Delete, - auth_method: None, - headers: None, - parent: None, - name: "Root2".to_string(), - uri: "/root2".to_string(), - body_type: None, - body: None, - }))) - } - - fn create_test_tree() -> Vec { - vec![create_root_one(), create_nested(), create_root_two()] - } - - #[test] - fn test_visit_next_no_expanded() { - let tree = create_test_tree(); - let mut dirs_expanded = HashMap::new(); - dirs_expanded.insert(create_dir().id, false); - let needle = create_nested(); - let expected = create_root_two(); - - let next = find_next_entry(&tree, VisitNode::Next, &dirs_expanded, &needle.get_id()); - - assert!(next.is_some()); - assert_eq!(next.unwrap().get_id(), expected.get_id()); - } - - #[test] - fn test_visit_node_nested_next() { - let tree = create_test_tree(); - let mut dirs_expanded = HashMap::new(); - dirs_expanded.insert(create_dir().id, true); - let needle = create_nested(); - let expected = create_child_one(); - - let next = find_next_entry(&tree, VisitNode::Next, &dirs_expanded, &needle.get_id()); - - assert!(next.is_some()); - assert_eq!(next.unwrap().get_id(), expected.get_id()); - } - - #[test] - fn test_visit_node_no_match() { - let tree = create_test_tree(); - let mut dirs_expanded = HashMap::new(); - dirs_expanded.insert(create_dir().id, true); - let needle = create_not_used(); - - let next = find_next_entry(&tree, VisitNode::Next, &dirs_expanded, &needle.get_id()); - - assert!(next.is_none()); - } - - #[test] - fn test_visit_node_nested_prev() { - let tree = create_test_tree(); - let mut dirs_expanded = HashMap::new(); - dirs_expanded.insert(create_dir().id, true); - let needle = create_child_one(); - let expected = create_nested(); - - let next = find_next_entry(&tree, VisitNode::Prev, &dirs_expanded, &needle.get_id()); - - assert!(next.is_some()); - assert_eq!(next.unwrap().get_id(), expected.get_id()); - } - - #[test] - fn test_visit_prev_into_nested() { - let tree = create_test_tree(); - let mut dirs_expanded = HashMap::new(); - dirs_expanded.insert(create_dir().id, true); - let needle = create_root_two(); - let expected = create_child_two(); - - let next = find_next_entry(&tree, VisitNode::Prev, &dirs_expanded, &needle.get_id()); - - assert!(next.is_some()); - assert_eq!(next.unwrap().get_id(), expected.get_id()); - } - - #[test] - fn test_empty_tree() { - let tree = vec![]; - let dirs_expanded = HashMap::new(); - let needle = create_root_two(); - - let next = find_next_entry(&tree, VisitNode::Next, &dirs_expanded, &needle.get_id()); - - assert!(next.is_none()); - } -} diff --git a/hac-client/src/pages/collection_viewer/collection_viewer.rs b/hac-client/src/pages/collection_viewer/collection_viewer.rs index 0a3f0a4..18af9f3 100755 --- a/hac-client/src/pages/collection_viewer/collection_viewer.rs +++ b/hac-client/src/pages/collection_viewer/collection_viewer.rs @@ -1,26 +1,36 @@ -use hac_core::collection::types::*; -use hac_core::command::Command; -use hac_core::net::request_manager::Response; - -use crate::pages::collection_viewer::collection_store::{CollectionStore, CollectionStoreAction}; -use crate::pages::collection_viewer::request_editor::{RequestEditor, RequestEditorEvent}; -use crate::pages::collection_viewer::request_uri::{RequestUri, RequestUriEvent}; -use crate::pages::collection_viewer::response_viewer::{ResponseViewer, ResponseViewerEvent}; -use crate::pages::collection_viewer::sidebar::{self, Sidebar, SidebarEvent}; -use crate::pages::{Eventful, Renderable}; - use std::cell::RefCell; use std::collections::HashMap; -use std::ops::{Add, Div}; +use std::ops::Div; use std::rc::Rc; +use std::sync::mpsc::{channel, Sender}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use hac_core::command::Command; +use hac_core::net::request_manager::Response; +use hac_store::collection::{Request, WhichSlab}; +use hac_store::slab::EntryRef; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::Stylize; use ratatui::widgets::{Block, Clear}; use ratatui::Frame; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; +use super::request_uri::{RequestUri, RequestUriEvent}; +use super::sidebar::create_request_form::CreateRequestForm; +use super::sidebar::delete_item_form::DeleteItemForm; +use super::sidebar::{Sidebar, SidebarEvent}; +use crate::app::Routes; +use crate::pages::collection_list::CollectionListData; +use crate::pages::collection_viewer::sidebar::edit_request_form::EditRequestForm; +//use crate::pages::collection_viewer::collection_store::{CollectionStore, CollectionStoreAction}; +//use crate::pages::collection_viewer::request_editor::{RequestEditor, RequestEditorEvent}; +//use crate::pages::collection_viewer::request_uri::{RequestUri, RequestUriEvent}; +use crate::pages::collection_viewer::response_viewer::{ResponseViewer, ResponseViewerEvent}; +//use crate::pages::collection_viewer::sidebar::{self, Sidebar, SidebarEvent}; +use crate::renderable::{Eventful, Renderable}; +use crate::router::RouterMessage; +use crate::{router_add_dialog, router_navigate_to, HacColors, HacConfig}; + #[derive(Debug, PartialEq)] pub struct ExplorerLayout { pub hint_pane: Rect, @@ -29,6 +39,7 @@ pub struct ExplorerLayout { pub req_editor: Rect, pub response_preview: Rect, pub create_req_form: Rect, + pub total_size: Rect, } #[derive(Debug, PartialEq, Eq, Clone)] @@ -75,56 +86,47 @@ impl PaneFocus { } #[derive(Debug)] -pub struct CollectionViewer<'cv> { - response_viewer: ResponseViewer<'cv>, - request_editor: RequestEditor<'cv>, - request_uri: RequestUri<'cv>, - sidebar: Sidebar<'cv>, - - colors: &'cv hac_colors::Colors, - config: &'cv hac_config::Config, +pub struct CollectionViewer { + focus: PaneFocus, + selection: Option, + request_uri: RequestUri, + sidebar: Sidebar, + messager: Sender, + + response_viewer: ResponseViewer, + + //request_editor: RequestEditor<'cv>, + colors: HacColors, + config: HacConfig, layout: ExplorerLayout, - global_command_sender: Option>, + global_command_sender: Option>, collection_sync_timer: std::time::Instant, - collection_store: Rc>, responses_map: HashMap>>, - response_rx: UnboundedReceiver, - request_tx: UnboundedSender, - dry_run: bool, + response_rx: UnboundedReceiver<(EntryRef, Response)>, + response_tx: UnboundedSender<(EntryRef, Response)>, } -impl<'cv> CollectionViewer<'cv> { - pub fn new( - size: Rect, - collection_store: Rc>, - colors: &'cv hac_colors::Colors, - config: &'cv hac_config::Config, - dry_run: bool, - ) -> Self { - let layout = build_layout(size); - let (request_tx, response_rx) = unbounded_channel::(); +impl CollectionViewer { + pub fn new(size: Rect, colors: HacColors, config: HacConfig) -> Self { + let layout = build_layout(size, 1); + let (response_tx, response_rx) = unbounded_channel::<(EntryRef, Response)>(); - let sidebar = sidebar::Sidebar::new(colors, collection_store.clone()); - - let request_editor = - RequestEditor::new(colors, config, collection_store.clone(), layout.req_editor); - - let response_viewer = ResponseViewer::new( - colors, - collection_store.clone(), - None, - layout.response_preview, - ); + //let request_editor = RequestEditor::new(colors, config, collection_store.clone(), layout.req_editor); - let request_uri = RequestUri::new(colors, collection_store.clone(), layout.req_uri); + let mut sidebar = Sidebar::new(colors.clone(), config.clone()); + sidebar.focus(); CollectionViewer { - request_editor, - response_viewer, + focus: PaneFocus::Sidebar, + selection: None, + request_uri: RequestUri::new(colors.clone(), layout.req_uri), sidebar, - request_uri, + messager: channel().0, + response_viewer: ResponseViewer::new(colors.clone(), layout.response_preview), + + //request_editor, colors, layout, config, @@ -132,379 +134,302 @@ impl<'cv> CollectionViewer<'cv> { collection_sync_timer: std::time::Instant::now(), responses_map: HashMap::default(), response_rx, - request_tx, - dry_run, - collection_store, + response_tx, } } - fn rebuild_everything(&mut self) { - self.sidebar = sidebar::Sidebar::new(self.colors, self.collection_store.clone()); - self.request_editor = RequestEditor::new( - self.colors, - self.config, - self.collection_store.clone(), - self.layout.req_editor, - ); - self.response_viewer = ResponseViewer::new( - self.colors, - self.collection_store.clone(), - None, - self.layout.response_preview, - ); - self.request_uri = RequestUri::new( - self.colors, - self.collection_store.clone(), - self.layout.req_uri, - ); - } - - fn focus_next(&mut self) { - let next_pane = self.collection_store.borrow().get_focused_pane().next(); - self.update_focus(next_pane); - } - - fn focus_prev(&mut self) { - let prev_pane = self.collection_store.borrow().get_focused_pane().prev(); - self.update_focus(prev_pane); - } - - // collect all pending responses from the channel. Here, I don't see a way we - // may have more than one response on this channel at any point, but it shouldn't matter - // if we have, so we can drain all the responses and update accordingly fn drain_responses_channel(&mut self) { - while let Ok(res) = self.response_rx.try_recv() { - let res = Rc::new(RefCell::new(res)); - self.collection_store - .borrow() - .get_selected_request() - .as_ref() - .and_then(|req| { - self.responses_map - .insert(req.read().unwrap().id.to_string(), Rc::clone(&res)) - }); - self.response_viewer.update(Some(Rc::clone(&res))); - self.response_rx.is_empty().then(|| { - self.collection_store - .borrow_mut() - .dispatch(CollectionStoreAction::SetPendingRequest(false)); - }); + while let Ok((entry_ref, response)) = self.response_rx.try_recv() { + hac_store::collection::restore_request(entry_ref); + self.response_viewer.update(Some(response)); + self.response_viewer.pending_request = false; } } - fn sync_collection_changes(&mut self) { - let sender = self - .global_command_sender - .as_ref() - .expect("should have a sender at this point") - .clone(); - - let mut collection = self - .collection_store - .borrow() - .get_collection() - .clone() - .expect("tried to sync collection to disk without having a collection") - .borrow() - .clone(); - if let Some(request) = self.collection_store.borrow().get_selected_request() { - let request = request.clone(); - let body = self.request_editor.body().to_string(); - // this is not the best idea for when we start implementing other kinds of - // body types like GraphQL - if !body.is_empty() { - request.write().unwrap().body = Some(body); - request.write().unwrap().body_type = Some(BodyType::Json) - } - - // we might later on decide to keep track of the actual dir/request index - // so we dont have to go over all the possible requests, this might be a - // problem for huge collections, but I haven't tested - collection - .requests - .as_mut() - .expect("no requests on collection, but we have a selected request") - .write() - .unwrap() - .iter_mut() - .for_each(|other| match other { - RequestKind::Single(inner) => { - if request.read().unwrap().id.eq(&inner.read().unwrap().id) { - *inner = request.clone(); - } - } - RequestKind::Nested(dir) => { - dir.requests.write().unwrap().iter_mut().for_each(|other| { - if let RequestKind::Single(inner) = other { - if request.read().unwrap().id.eq(&inner.read().unwrap().id) { - *inner = request.clone(); - } - } - }) - } - }); - } - - self.collection_sync_timer = std::time::Instant::now(); + //fn sync_collection_changes(&mut self) { + // let sender = self + // .global_command_sender + // .as_ref() + // .expect("should have a sender at this point") + // .clone(); + // + // let mut collection = self + // .collection_store + // .borrow() + // .get_collection() + // .clone() + // .expect("tried to sync collection to disk without having a collection") + // .borrow() + // .clone(); + // if let Some(request) = self.collection_store.borrow().get_selected_request() { + // let request = request.clone(); + // let body = self.request_editor.body().to_string(); + // // this is not the best idea for when we start implementing other kinds of + // // body types like GraphQL + // if !body.is_empty() { + // request.write().unwrap().body = Some(body); + // request.write().unwrap().body_type = Some(BodyType::Json) + // } + // + // // we might later on decide to keep track of the actual dir/request index + // // so we dont have to go over all the possible requests, this might be a + // // problem for huge collections, but I haven't tested + // collection + // .requests + // .as_mut() + // .expect("no requests on collection, but we have a selected request") + // .write() + // .unwrap() + // .iter_mut() + // .for_each(|other| match other { + // RequestKind::Single(inner) => { + // if request.read().unwrap().id.eq(&inner.read().unwrap().id) { + // *inner = request.clone(); + // } + // } + // RequestKind::Nested(dir) => dir.requests.write().unwrap().iter_mut().for_each(|other| { + // if let RequestKind::Single(inner) = other { + // if request.read().unwrap().id.eq(&inner.read().unwrap().id) { + // *inner = request.clone(); + // } + // } + // }), + // }); + // } + //self.collection_sync_timer = std::time::Instant::now(); + //if self.dry_run { + // return; + //} + //tokio::spawn(async move { + // match hac_core::fs::sync_collection(collection).await { + // Ok(_) => {} + // Err(e) => { + // if sender.send(Command::Error(e.to_string())).is_err() { + // tracing::error!("failed to send error command through channel"); + // std::process::abort(); + // } + // } + // } + //}); + //} - if self.dry_run { + fn update_selection(&mut self, pane_to_select: Option) { + if hac_store::collection::is_empty() { return; } - tokio::spawn(async move { - match hac_core::fs::sync_collection(collection).await { - Ok(_) => {} - Err(e) => { - if sender.send(Command::Error(e.to_string())).is_err() { - tracing::error!("failed to send error command through channel"); - std::process::abort(); - } - } + match self.selection { + Some(PaneFocus::Sidebar) => self.sidebar.deselect(), + Some(PaneFocus::ReqUri) => self.request_uri.deselect(), + Some(PaneFocus::Preview) => self.response_viewer.deselect(), + Some(PaneFocus::Editor) => {} + None => {} + } + match pane_to_select { + Some(pane @ PaneFocus::Sidebar) => { + self.sidebar.select(); + self.focus = pane; + } + Some(pane @ PaneFocus::ReqUri) => { + self.request_uri.select(); + self.focus = pane; } - }); + Some(pane @ PaneFocus::Preview) => { + self.response_viewer.select(); + self.focus = pane; + } + Some(pane @ PaneFocus::Editor) => { + self.focus = pane; + } + None => {} + } + self.selection = pane_to_select; } - fn update_selection(&mut self, pane_to_select: Option) { - self.collection_store - .borrow_mut() - .dispatch(CollectionStoreAction::SetSelectedPane(pane_to_select)); + fn update_focus(&mut self, pane_to_focus: PaneFocus) { + match self.focus { + PaneFocus::Sidebar => self.sidebar.blur(), + PaneFocus::ReqUri => self.request_uri.blur(), + PaneFocus::Preview => self.response_viewer.blur(), + PaneFocus::Editor => {} + } + match pane_to_focus { + PaneFocus::Sidebar => self.sidebar.focus(), + PaneFocus::ReqUri => self.request_uri.focus(), + PaneFocus::Preview => self.response_viewer.focus(), + PaneFocus::Editor => {} + } + self.focus = pane_to_focus; } - fn update_focus(&mut self, pane_to_focus: PaneFocus) { - self.collection_store - .borrow_mut() - .dispatch(CollectionStoreAction::SetFocusedPane(pane_to_focus)); + #[inline] + fn focus_next(&mut self) { + let next = self.focus.next(); + self.update_focus(next); + } + + #[inline] + fn focus_prev(&mut self) { + let prev = self.focus.prev(); + self.update_focus(prev); } } -impl Renderable for CollectionViewer<'_> { +impl Renderable for CollectionViewer { + type Input = CollectionListData; + type Output = (); + #[tracing::instrument(skip_all)] fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()> { - // we redraw the background to prevent weird "transparent" spots when popups are - // cleared from the buffer frame.render_widget(Clear, size); frame.render_widget(Block::default().bg(self.colors.primary.background), size); self.drain_responses_channel(); - self.sidebar.draw(frame, self.layout.sidebar)?; - self.response_viewer - .draw(frame, self.layout.response_preview)?; - self.request_editor.draw(frame, self.layout.req_editor)?; self.request_uri.draw(frame, self.layout.req_uri)?; - - let overlay = self.collection_store.borrow().peek_overlay(); - match overlay { - CollectionViewerOverlay::CreateRequest => { - self.sidebar.draw_overlay(frame, overlay)?; - } - CollectionViewerOverlay::CreateDirectory => { - self.sidebar.draw_overlay(frame, overlay)?; - } - CollectionViewerOverlay::SelectParentDir => { - self.sidebar.draw_overlay(frame, overlay)?; - } - CollectionViewerOverlay::EditRequest => { - self.sidebar.draw_overlay(frame, overlay)?; - } - CollectionViewerOverlay::EditDirectory => { - self.sidebar.draw_overlay(frame, overlay)?; - } - CollectionViewerOverlay::DeleteSidebarItem(_) => { - self.sidebar.draw_overlay(frame, overlay)?; - } - CollectionViewerOverlay::HeadersHelp => { - self.request_editor.draw_overlay(frame, overlay)?; - } - CollectionViewerOverlay::HeadersDelete => { - self.request_editor.draw_overlay(frame, overlay)?; - } - CollectionViewerOverlay::HeadersForm(_, _) => { - self.request_editor.draw_overlay(frame, overlay)?; - } - CollectionViewerOverlay::ChangeAuthMethod => { - self.request_editor.draw_overlay(frame, overlay)?; - } - CollectionViewerOverlay::None => {} - } - - if self - .collection_store - .borrow() - .get_selected_pane() - .as_ref() - .is_some_and(|pane| pane.eq(&PaneFocus::Editor)) - { - self.request_editor.maybe_draw_cursor(frame); - } - - if self - .collection_store - .borrow() - .get_selected_pane() - .as_ref() - .is_some_and(|pane| pane.eq(&PaneFocus::ReqUri)) - { - if let Some(request) = self - .collection_store - .borrow() - .get_selected_request() - .as_ref() - { - frame.set_cursor( - self.layout - .req_uri - .x - .add(request.read().unwrap().uri.chars().count() as u16) - .add(1), - self.layout.req_uri.y.add(1), - ) - } - } + self.sidebar.draw(frame, self.layout.sidebar)?; + self.response_viewer.draw(frame, self.layout.response_preview)?; + //self.request_editor.draw(frame, self.layout.req_editor)?; Ok(()) } - fn handle_tick(&mut self) -> anyhow::Result<()> { - if self.collection_sync_timer.elapsed().as_secs().ge(&5) { - self.sync_collection_changes(); + fn tick(&mut self) -> anyhow::Result<()> { + if self.collection_sync_timer.elapsed().as_secs() > self.config.borrow().sync_delay { + //self.sync_collection_changes(); } Ok(()) } - fn register_command_handler(&mut self, sender: UnboundedSender) -> anyhow::Result<()> { + fn data(&self, _requester: u8) -> Self::Output {} + + fn register_command_handler(&mut self, sender: Sender) { self.global_command_sender = Some(sender); - Ok(()) } fn resize(&mut self, new_size: Rect) { - let new_layout = build_layout(new_size); - self.request_editor.resize(new_layout.req_editor); + let new_layout = build_layout(new_size, 1); self.response_viewer.resize(new_layout.response_preview); + //self.request_editor.resize(new_layout.req_editor); self.layout = new_layout; } + + fn attach_navigator(&mut self, messager: Sender) { + self.messager = messager; + } } -impl Eventful for CollectionViewer<'_> { +impl Eventful for CollectionViewer { type Result = Command; fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { - if let ( - None, - KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - .. - }, - ) = ( - self.collection_store.borrow().get_selected_pane(), - key_event, - ) { + if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { return Ok(Some(Command::Quit)); } - if self.collection_store.borrow().get_selected_pane().is_none() { + if self.selection.is_none() { match key_event.code { - KeyCode::Char('r') => { - self.update_focus(PaneFocus::Sidebar); - self.update_selection(Some(PaneFocus::Sidebar)); - } - KeyCode::Char('u') => { - self.update_focus(PaneFocus::ReqUri); - self.update_selection(Some(PaneFocus::ReqUri)); - } - KeyCode::Char('p') => { - self.update_focus(PaneFocus::Preview); - self.update_selection(Some(PaneFocus::Preview)); - } - KeyCode::Char('e') => { - self.update_focus(PaneFocus::Editor); - self.update_selection(Some(PaneFocus::Editor)); - } + KeyCode::Char('r') => self.update_selection(Some(PaneFocus::Sidebar)), + KeyCode::Char('u') => self.update_selection(Some(PaneFocus::ReqUri)), + KeyCode::Char('p') => self.update_selection(Some(PaneFocus::Preview)), + KeyCode::Char('e') => self.update_selection(Some(PaneFocus::Editor)), KeyCode::Tab => self.focus_next(), KeyCode::BackTab => self.focus_prev(), - KeyCode::Enter => { - let curr_pane = self.collection_store.borrow().get_focused_pane(); - self.update_selection(Some(curr_pane)); - } + KeyCode::Enter => self.update_selection(Some(self.focus)), _ => {} } return Ok(None); } - let selected_pane = self.collection_store.borrow().get_selected_pane(); - if let Some(curr_pane) = selected_pane { - match curr_pane { - PaneFocus::Sidebar => match self.sidebar.handle_key_event(key_event)? { - Some(SidebarEvent::CreateRequest) => self - .collection_store - .borrow_mut() - .push_overlay(CollectionViewerOverlay::CreateRequest), - Some(SidebarEvent::EditRequest) => self - .collection_store - .borrow_mut() - .push_overlay(CollectionViewerOverlay::EditRequest), - Some(SidebarEvent::EditDirectory) => self - .collection_store - .borrow_mut() - .push_overlay(CollectionViewerOverlay::EditDirectory), - Some(SidebarEvent::CreateDirectory) => self - .collection_store - .borrow_mut() - .push_overlay(CollectionViewerOverlay::CreateDirectory), - Some(SidebarEvent::DeleteItem(item_id)) => self - .collection_store - .borrow_mut() - .push_overlay(CollectionViewerOverlay::DeleteSidebarItem(item_id)), - Some(SidebarEvent::RemoveSelection) => self.update_selection(None), - Some(SidebarEvent::SelectNext) => { + if let Some(pane) = self.selection { + match pane { + PaneFocus::ReqUri => match self.request_uri.handle_key_event(key_event)? { + Some(RequestUriEvent::RemoveSelection) => self.update_selection(None), + Some(RequestUriEvent::SendRequest) => { + let request_ref = hac_store::collection::borrow_selected_request(); + hac_core::net::handle_request(request_ref, self.response_tx.clone()); + self.response_viewer.pending_request = true; + } + Some(RequestUriEvent::SelectNext) => { self.update_selection(None); self.focus_next(); } - Some(SidebarEvent::SelectPrev) => { + Some(RequestUriEvent::SelectPrev) => { self.update_selection(None); self.focus_prev(); } - Some(SidebarEvent::SyncCollection) => self.sync_collection_changes(), - Some(SidebarEvent::Quit) => return Ok(Some(Command::Quit)), - Some(SidebarEvent::RebuildView) => self.rebuild_everything(), - // when theres no event we do nothing None => {} }, - PaneFocus::ReqUri => match self.request_uri.handle_key_event(key_event)? { - Some(RequestUriEvent::Quit) => return Ok(Some(Command::Quit)), - Some(RequestUriEvent::SendRequest) => hac_core::net::handle_request( - self.collection_store - .borrow() - .get_selected_request() - .as_ref() - .unwrap(), - self.request_tx.clone(), - ), - Some(RequestUriEvent::RemoveSelection) => self.update_selection(None), - Some(RequestUriEvent::SelectNext) => { + PaneFocus::Sidebar => match self.sidebar.handle_key_event(key_event)? { + Some(SidebarEvent::RemoveSelection) => self.update_selection(None), + Some(SidebarEvent::ShowExtendedHint) => self.layout = build_layout(self.layout.total_size, 3), + Some(SidebarEvent::HideExtendedHint) => self.layout = build_layout(self.layout.total_size, 1), + Some(SidebarEvent::SelectNext) => { + self.layout = build_layout(self.layout.total_size, 1); self.update_selection(None); self.focus_next(); } - Some(RequestUriEvent::SelectPrev) => { + Some(SidebarEvent::SelectPrev) => { + self.layout = build_layout(self.layout.total_size, 1); self.update_selection(None); self.focus_prev(); } - // when theres no event we do nothing + Some(SidebarEvent::DeleteItem) => { + let delete_item_form = DeleteItemForm::new(self.colors.clone(), self.layout.total_size); + router_add_dialog!(&self.messager, Routes::DeleteItem, delete_item_form); + router_navigate_to!(&self.messager, Routes::DeleteItem); + } + Some(SidebarEvent::CreateRequest) => { + tracing::trace!("opening create request form from sidebar"); + let create_request_form = CreateRequestForm::new(self.colors.clone(), self.layout.total_size); + router_add_dialog!(&self.messager, Routes::CreateRequest, create_request_form); + router_navigate_to!(&self.messager, Routes::CreateRequest); + } + Some(SidebarEvent::EditRequest) => { + let entry = hac_store::collection::get_hovered_request(|entry| entry); + if let Some((which, key)) = entry { + match which { + WhichSlab::Requests => hac_store::collection::get_request(key, |req, _| { + let edit_request_form = EditRequestForm::new( + req.name.clone(), + req.method, + key, + req.parent, + self.colors.clone(), + self.layout.total_size, + ); + router_add_dialog!(&self.messager, Routes::EditRequest, edit_request_form); + router_navigate_to!(&self.messager, Routes::EditRequest); + }), + WhichSlab::RootRequests => hac_store::collection::get_root_request(key, |req, _| { + let edit_request_form = EditRequestForm::new( + req.name.clone(), + req.method, + key, + req.parent, + self.colors.clone(), + self.layout.total_size, + ); + router_add_dialog!(&self.messager, Routes::EditRequest, edit_request_form); + router_navigate_to!(&self.messager, Routes::EditRequest); + }), + _ => {} + }; + } + } None => {} }, PaneFocus::Preview => match self.response_viewer.handle_key_event(key_event)? { Some(ResponseViewerEvent::RemoveSelection) => self.update_selection(None), - Some(ResponseViewerEvent::Quit) => return Ok(Some(Command::Quit)), - // when theres no event we do nothing - None => {} - }, - PaneFocus::Editor => match self.request_editor.handle_key_event(key_event)? { - Some(RequestEditorEvent::RemoveSelection) => self.update_selection(None), - Some(RequestEditorEvent::Quit) => return Ok(Some(Command::Quit)), - // when theres no event we do nothing - None => {} + _ => {} }, + // PaneFocus::Editor => match self.request_editor.handle_key_event(key_event)? { + // Some(RequestEditorEvent::RemoveSelection) => self.update_selection(None), + // Some(RequestEditorEvent::Quit) => return Ok(Some(Command::Quit)), + // // when theres no event we do nothing + // None => {} + // }, + _ => todo!(), }; } @@ -512,10 +437,10 @@ impl Eventful for CollectionViewer<'_> { } } -pub fn build_layout(size: Rect) -> ExplorerLayout { +pub fn build_layout(size: Rect, hint_size: u16) -> ExplorerLayout { let [top_pane, hint_pane] = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Fill(1), Constraint::Length(1)]) + .constraints([Constraint::Fill(1), Constraint::Length(hint_size)]) .areas(size); let [sidebar, right_pane] = Layout::default() @@ -554,5 +479,6 @@ pub fn build_layout(size: Rect) -> ExplorerLayout { req_editor, response_preview, create_req_form, + total_size: size, } } diff --git a/hac-client/src/pages/collection_viewer/mod.rs b/hac-client/src/pages/collection_viewer/mod.rs index c5b2353..f0deab1 100755 --- a/hac-client/src/pages/collection_viewer/mod.rs +++ b/hac-client/src/pages/collection_viewer/mod.rs @@ -1,7 +1,6 @@ -pub mod collection_store; #[allow(clippy::module_inception)] pub mod collection_viewer; -mod request_editor; +//mod request_editor; mod request_uri; mod response_viewer; mod sidebar; diff --git a/hac-client/src/pages/collection_viewer/request_editor/auth_editor/auth_kind_prompt.rs b/hac-client/src/pages/collection_viewer/request_editor/auth_editor/auth_kind_prompt.rs index 535cf7b..77c4cdc 100644 --- a/hac-client/src/pages/collection_viewer/request_editor/auth_editor/auth_kind_prompt.rs +++ b/hac-client/src/pages/collection_viewer/request_editor/auth_editor/auth_kind_prompt.rs @@ -1,11 +1,4 @@ -use crate::ascii::LOGO_ASCII; -use crate::components::component_styles::ComponentBorder; -use crate::components::list_item::{list_item, ListItemKind}; -use crate::pages::collection_viewer::collection_store::CollectionStore; -use crate::pages::{overlay::make_overlay, Eventful, Renderable}; - use std::cell::RefCell; -use std::ops::{Add, Div, Sub}; use std::rc::Rc; use crossterm::event::{KeyCode, KeyEvent}; @@ -17,6 +10,13 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; +use crate::ascii::LOGO_ASCII; +use crate::components::component_styles::ComponentBorder; +use crate::components::list_item::{list_item, ListItemKind}; +use crate::pages::collection_viewer::collection_store::CollectionStore; +use crate::pages::overlay::make_overlay_old; +use crate::pages::{Eventful, Renderable}; + pub enum AuthKindPromptEvent { Confirm(AuthMethod), Cancel, @@ -27,39 +27,28 @@ pub struct AuthKindPrompt<'akp> { colors: &'akp hac_colors::Colors, collection_store: Rc>, selected_idx: usize, - logo_idx: usize, } impl<'akp> AuthKindPrompt<'akp> { - pub fn new( - colors: &'akp hac_colors::Colors, - collection_store: Rc>, - ) -> AuthKindPrompt { - let logo_idx = rand::thread_rng().gen_range(0..LOGO_ASCII.len()); - + pub fn new(colors: &'akp hac_colors::Colors, collection_store: Rc>) -> AuthKindPrompt { AuthKindPrompt { colors, collection_store, selected_idx: 0, - logo_idx, } } } impl Renderable for AuthKindPrompt<'_> { fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()> { - make_overlay(self.colors, self.colors.normal.black, 0.1, frame); + make_overlay_old(self.colors, self.colors.normal.black, 0.1, frame); - let logo = LOGO_ASCII[self.logo_idx]; + let logo = LOGO_ASCII; let logo_size = logo.len() as u16; // adding size of the form + spacing + hint let [_, center, _] = Layout::default() - .constraints([ - Constraint::Fill(1), - Constraint::Min(80), - Constraint::Fill(1), - ]) + .constraints([Constraint::Fill(1), Constraint::Min(80), Constraint::Fill(1)]) .direction(Direction::Horizontal) .areas(size); diff --git a/hac-client/src/pages/collection_viewer/request_editor/headers_editor/headers_editor.rs b/hac-client/src/pages/collection_viewer/request_editor/headers_editor/headers_editor.rs index 64ba927..df4b1bd 100755 --- a/hac-client/src/pages/collection_viewer/request_editor/headers_editor/headers_editor.rs +++ b/hac-client/src/pages/collection_viewer/request_editor/headers_editor/headers_editor.rs @@ -1,10 +1,6 @@ -use crate::ascii::LOGO_ASCII; -use crate::pages::collection_viewer::collection_viewer::CollectionViewerOverlay; -use crate::pages::overlay::make_overlay; -use crate::pages::{collection_viewer::collection_store::CollectionStore, Eventful, Renderable}; - -use std::ops::{Div, Mul, Sub}; -use std::{cell::RefCell, ops::Add, rc::Rc}; +use std::cell::RefCell; +use std::ops::{Add, Div, Mul, Sub}; +use std::rc::Rc; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use hac_core::collection::types::HeaderMap; @@ -12,15 +8,16 @@ use rand::Rng; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Style, Stylize}; use ratatui::text::Line; -use ratatui::widgets::{ - Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, -}; +use ratatui::widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}; use ratatui::Frame; -use super::headers_editor_delete_prompt::{ - HeadersEditorDeletePrompt, HeadersEditorDeletePromptEvent, -}; +use super::headers_editor_delete_prompt::{HeadersEditorDeletePrompt, HeadersEditorDeletePromptEvent}; use super::headers_editor_edit_form::{HeadersEditorForm, HeadersEditorFormEvent}; +use crate::ascii::LOGO_ASCII; +use crate::pages::collection_viewer::collection_store::CollectionStore; +use crate::pages::collection_viewer::collection_viewer::CollectionViewerOverlay; +use crate::pages::overlay::make_overlay_old; +use crate::pages::{Eventful, Renderable}; #[derive(Debug)] pub enum HeadersEditorEvent { @@ -46,7 +43,6 @@ pub struct HeadersEditor<'he> { row_height: u16, amount_on_view: usize, layout: HeadersEditorLayout, - logo_idx: usize, delete_prompt: HeadersEditorDeletePrompt<'he>, header_form: HeadersEditorForm<'he>, @@ -60,7 +56,6 @@ impl<'he> HeadersEditor<'he> { ) -> Self { let row_height = 2; let layout = build_layout(size, row_height); - let logo_idx = rand::thread_rng().gen_range(0..LOGO_ASCII.len()); HeadersEditor { delete_prompt: HeadersEditorDeletePrompt::new(colors), @@ -73,7 +68,6 @@ impl<'he> HeadersEditor<'he> { row_height, amount_on_view: layout.content_size.height.div_ceil(row_height).into(), layout, - logo_idx, } } @@ -115,14 +109,11 @@ impl<'he> HeadersEditor<'he> { w if w.le(&100) => "[j/k -> move down/up] [enter -> select] [space -> enable/disable] [? -> help]", _ => "[j/k -> move down/up] [enter -> select] [space -> enable/disable] [d -> delete] [? -> help]", }; - frame.render_widget( - Paragraph::new(hint).fg(self.colors.bright.black).centered(), - hint_size, - ); + frame.render_widget(Paragraph::new(hint).fg(self.colors.bright.black).centered(), hint_size); } fn draw_help_overlay(&self, frame: &mut Frame) { - make_overlay(self.colors, self.colors.normal.black, 0.1, frame); + make_overlay_old(self.colors, self.colors.normal.black, 0.1, frame); let lines = [ [ @@ -139,18 +130,15 @@ impl<'he> HeadersEditor<'he> { ], [ format!("space{}", " ".repeat(7)).fg(self.colors.normal.red), - format!("- enables or disabled header{}", " ".repeat(12)) - .fg(self.colors.normal.yellow), + format!("- enables or disabled header{}", " ".repeat(12)).fg(self.colors.normal.yellow), ], [ format!("enter{}", " ".repeat(7)).fg(self.colors.normal.red), - format!("- select header for editing{}", " ".repeat(13)) - .fg(self.colors.normal.yellow), + format!("- select header for editing{}", " ".repeat(13)).fg(self.colors.normal.yellow), ], [ format!("?{}", " ".repeat(11)).fg(self.colors.normal.red), - format!("- shows this help message{}", " ".repeat(15)) - .fg(self.colors.normal.yellow), + format!("- shows this help message{}", " ".repeat(15)).fg(self.colors.normal.yellow), ], ]; @@ -159,7 +147,7 @@ impl<'he> HeadersEditor<'he> { .map(|l| Line::from(l.into_iter().collect::>())) .collect(); - let mut logo = LOGO_ASCII[self.logo_idx]; + let mut logo = LOGO_ASCII; let size = frame.size(); let logo_size = logo.len(); // we are adding 2 spaces for the gap between the logo and the text @@ -187,12 +175,7 @@ impl<'he> HeadersEditor<'he> { .chain(lines) .collect::>(); - let hint_size = Rect::new( - popup_size.x, - popup_size.y.add(popup_size.height).add(1), - 40, - 1, - ); + let hint_size = Rect::new(popup_size.x, popup_size.y.add(popup_size.height).add(1), 40, 1); let hint = Line::from("press any key to close this dialog") .fg(self.colors.bright.black) @@ -205,22 +188,16 @@ impl<'he> HeadersEditor<'he> { pub fn draw_empty_message(&self, frame: &mut Frame) { let size = self.layout.content_size; let no_headers = "No headers method".fg(self.colors.bright.black); - let no_request = Paragraph::new(no_headers).centered().block( - Block::default() - .fg(self.colors.normal.white) - .borders(Borders::ALL), - ); + let no_request = Paragraph::new(no_headers) + .centered() + .block(Block::default().fg(self.colors.normal.white).borders(Borders::ALL)); let size = Rect::new(size.x.add(5), size.y.sub(2), size.width.sub(10), 3); frame.render_widget(no_request, size); self.draw_hint(frame); } - pub fn draw_overlay( - &mut self, - frame: &mut Frame, - overlay: CollectionViewerOverlay, - ) -> anyhow::Result<()> { + pub fn draw_overlay(&mut self, frame: &mut Frame, overlay: CollectionViewerOverlay) -> anyhow::Result<()> { match overlay { CollectionViewerOverlay::HeadersHelp => self.draw_help_overlay(frame), CollectionViewerOverlay::HeadersDelete => { @@ -254,16 +231,9 @@ impl Renderable for HeadersEditor<'_> { let headers = headers.unwrap(); let title_name = Paragraph::new("Name").fg(self.colors.normal.yellow).bold(); let title_value = Paragraph::new("Value").fg(self.colors.normal.yellow).bold(); - let title_enabled = Paragraph::new("Enabled") - .fg(self.colors.normal.yellow) - .bold(); + let title_enabled = Paragraph::new("Enabled").fg(self.colors.normal.yellow).bold(); - for (idx, header) in headers - .iter() - .skip(self.scroll) - .take(self.amount_on_view) - .enumerate() - { + for (idx, header) in headers.iter().skip(self.scroll).take(self.amount_on_view).enumerate() { let size = self.layout.content_size; let offset = self.row_height.mul(idx as u16); let size = Rect::new(size.x, size.y.add(offset), size.width, self.row_height); @@ -315,12 +285,7 @@ impl Renderable for HeadersEditor<'_> { fn resize(&mut self, new_size: Rect) { self.layout = build_layout(new_size, self.row_height); - self.amount_on_view = self - .layout - .content_size - .height - .div_ceil(self.row_height) - .into(); + self.amount_on_view = self.layout.content_size.height.div_ceil(self.row_height).into(); } } @@ -392,11 +357,7 @@ impl Eventful for HeadersEditor<'_> { let mut request = request.write().unwrap(); - let total_headers = request - .headers - .as_ref() - .map(|h| h.len()) - .unwrap_or_default(); + let total_headers = request.headers.as_ref().map(|h| h.len()).unwrap_or_default(); match key_event.code { KeyCode::Char('j') | KeyCode::Down => { @@ -461,9 +422,9 @@ impl Eventful for HeadersEditor<'_> { }; drop(request); - self.collection_store.borrow_mut().push_overlay( - CollectionViewerOverlay::HeadersForm(self.selected_row, false), - ); + self.collection_store + .borrow_mut() + .push_overlay(CollectionViewerOverlay::HeadersForm(self.selected_row, false)); } } KeyCode::Esc => return Ok(Some(HeadersEditorEvent::RemoveSelection)), diff --git a/hac-client/src/pages/collection_viewer/request_editor/headers_editor/headers_editor_delete_prompt.rs b/hac-client/src/pages/collection_viewer/request_editor/headers_editor/headers_editor_delete_prompt.rs index 21d122e..8ffa12e 100644 --- a/hac-client/src/pages/collection_viewer/request_editor/headers_editor/headers_editor_delete_prompt.rs +++ b/hac-client/src/pages/collection_viewer/request_editor/headers_editor/headers_editor_delete_prompt.rs @@ -1,14 +1,16 @@ use std::ops::{Add, Div}; -use crate::ascii::LOGO_ASCII; -use crate::pages::{overlay::make_overlay, Eventful, Renderable}; - use crossterm::event::{KeyCode, KeyEvent}; use rand::Rng; +use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; -use ratatui::{layout::Rect, Frame}; +use ratatui::Frame; + +use crate::ascii::LOGO_ASCII; +use crate::pages::overlay::make_overlay_old; +use crate::pages::{Eventful, Renderable}; /// set of events `HeadersEditorDeletePrompt` can emit to its parent #[derive(Debug, PartialEq, Eq, Clone, Copy)] @@ -24,19 +26,17 @@ pub enum HeadersEditorDeletePromptEvent { #[derive(Debug)] pub struct HeadersEditorDeletePrompt<'hedp> { colors: &'hedp hac_colors::Colors, - logo_idx: usize, } impl<'hedp> HeadersEditorDeletePrompt<'hedp> { pub fn new(colors: &'hedp hac_colors::Colors) -> Self { - let logo_idx = rand::thread_rng().gen_range(0..LOGO_ASCII.len()); - HeadersEditorDeletePrompt { colors, logo_idx } + HeadersEditorDeletePrompt { colors } } } impl Renderable for HeadersEditorDeletePrompt<'_> { fn draw(&mut self, frame: &mut Frame, _: Rect) -> anyhow::Result<()> { - make_overlay(self.colors, self.colors.normal.black, 0.1, frame); + make_overlay_old(self.colors, self.colors.normal.black, 0.1, frame); let lines: Vec = vec![ "are you sure you want to delete this header?" @@ -52,7 +52,7 @@ impl Renderable for HeadersEditorDeletePrompt<'_> { .centered(), ]; - let logo = LOGO_ASCII[self.logo_idx]; + let logo = LOGO_ASCII; let size = frame.size(); let logo_size = logo.len(); // we are adding 2 spaces for the gap between the logo and the text @@ -85,12 +85,8 @@ impl Eventful for HeadersEditorDeletePrompt<'_> { fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { match key_event.code { - KeyCode::Char('y') | KeyCode::Char('Y') => { - Ok(Some(HeadersEditorDeletePromptEvent::Confirm)) - } - KeyCode::Char('n') | KeyCode::Char('N') => { - Ok(Some(HeadersEditorDeletePromptEvent::Cancel)) - } + KeyCode::Char('y') | KeyCode::Char('Y') => Ok(Some(HeadersEditorDeletePromptEvent::Confirm)), + KeyCode::Char('n') | KeyCode::Char('N') => Ok(Some(HeadersEditorDeletePromptEvent::Cancel)), _ => Ok(None), } } diff --git a/hac-client/src/pages/collection_viewer/request_editor/headers_editor/headers_editor_edit_form.rs b/hac-client/src/pages/collection_viewer/request_editor/headers_editor/headers_editor_edit_form.rs index 026e247..41e5adc 100644 --- a/hac-client/src/pages/collection_viewer/request_editor/headers_editor/headers_editor_edit_form.rs +++ b/hac-client/src/pages/collection_viewer/request_editor/headers_editor/headers_editor_edit_form.rs @@ -1,10 +1,3 @@ -use crate::ascii::LOGO_ASCII; -use crate::pages::collection_viewer::collection_store::CollectionStore; -use crate::pages::collection_viewer::collection_viewer::CollectionViewerOverlay; -use crate::pages::input::Input; -use crate::pages::overlay::make_overlay; -use crate::pages::{Eventful, Renderable}; - use std::cell::RefCell; use std::ops::{Add, Div, Sub}; use std::rc::Rc; @@ -17,6 +10,13 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::Frame; +use crate::ascii::LOGO_ASCII; +use crate::pages::collection_viewer::collection_store::CollectionStore; +use crate::pages::collection_viewer::collection_viewer::CollectionViewerOverlay; +use crate::pages::input::Input; +use crate::pages::overlay::make_overlay_old; +use crate::pages::{Eventful, Renderable}; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HeadersEditorFormEvent { FinishEdit, @@ -44,24 +44,17 @@ pub struct HeadersEditorForm<'hef> { colors: &'hef hac_colors::Colors, collection_store: Rc>, header_idx: usize, - logo_idx: usize, focused_input: HeadersEditorFormInput, original_name: String, original_value: String, } impl<'hef> HeadersEditorForm<'hef> { - pub fn new( - colors: &'hef hac_colors::Colors, - collection_store: Rc>, - ) -> HeadersEditorForm { - let logo_idx = rand::thread_rng().gen_range(0..LOGO_ASCII.len()); - + pub fn new(colors: &'hef hac_colors::Colors, collection_store: Rc>) -> HeadersEditorForm { HeadersEditorForm { colors, header_idx: 0, collection_store, - logo_idx, focused_input: HeadersEditorFormInput::Name, original_name: String::default(), original_value: String::default(), @@ -89,9 +82,7 @@ impl<'hef> HeadersEditorForm<'hef> { anyhow::bail!("tried to display the header form without the proper overlay set"); }; - let header = headers - .get(idx) - .expect("selected a non-existing header to edit"); + let header = headers.get(idx).expect("selected a non-existing header to edit"); self.original_name = header.pair.0.to_string(); self.original_value = header.pair.1.to_string(); @@ -108,7 +99,7 @@ impl<'hef> HeadersEditorForm<'hef> { impl Renderable for HeadersEditorForm<'_> { #[tracing::instrument(skip_all, err)] fn draw(&mut self, frame: &mut Frame, _: Rect) -> anyhow::Result<()> { - make_overlay(self.colors, self.colors.normal.black, 0.1, frame); + make_overlay_old(self.colors, self.colors.normal.black, 0.1, frame); let store = self.collection_store.borrow_mut(); let Some(request) = store.get_selected_request() else { @@ -124,22 +115,17 @@ impl Renderable for HeadersEditorForm<'_> { anyhow::bail!("tried to display the header form without the proper overlay set"); }; - let header = headers - .get(idx) - .expect("selected a non-existing header to edit"); + let header = headers.get(idx).expect("selected a non-existing header to edit"); let size = frame.size(); - let mut logo = LOGO_ASCII[self.logo_idx]; + let mut logo = LOGO_ASCII; let mut logo_size = logo.len() as u16; let total_size = logo_size.add(11).add(2); let mut size = Rect::new( size.width.div(2).sub(25), - size.height - .div(2) - .saturating_sub(logo_size.div(2)) - .saturating_sub(5), + size.height.div(2).saturating_sub(logo_size.div(2)).saturating_sub(5), 50, logo_size.add(11), ); @@ -153,10 +139,8 @@ impl Renderable for HeadersEditorForm<'_> { let mut name_input = Input::new(self.colors, "Name".into()); let mut value_input = Input::new(self.colors, "Value".into()); - let hint = Paragraph::new( - "Press enter to confirm, press esc to cancel".fg(self.colors.bright.black), - ) - .centered(); + let hint = + Paragraph::new("Press enter to confirm, press esc to cancel".fg(self.colors.bright.black)).centered(); match self.focused_input { HeadersEditorFormInput::Name => name_input.focus(), @@ -193,9 +177,7 @@ impl Renderable for HeadersEditorForm<'_> { } HeadersEditorFormInput::Value => { frame.set_cursor( - value_size - .x - .add(header.pair.1.chars().count().add(1) as u16), + value_size.x.add(header.pair.1.chars().count().add(1) as u16), value_size.y.add(1), ); } diff --git a/hac-client/src/pages/collection_viewer/request_uri.rs b/hac-client/src/pages/collection_viewer/request_uri.rs index 1e651c3..291cac0 100755 --- a/hac-client/src/pages/collection_viewer/request_uri.rs +++ b/hac-client/src/pages/collection_viewer/request_uri.rs @@ -1,17 +1,12 @@ -use crate::pages::collection_viewer::collection_store::CollectionStore; -use crate::pages::collection_viewer::collection_store::CollectionStoreAction; -use crate::pages::collection_viewer::collection_viewer::PaneFocus; -use crate::pages::{Eventful, Renderable}; - -use std::cell::RefCell; -use std::rc::Rc; - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::layout::Rect; use ratatui::style::{Style, Stylize}; use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; +use crate::renderable::{Eventful, Renderable}; +use crate::HacColors; + /// Set of events RequestUri can send back to the caller when handling key_events #[derive(Debug)] pub enum RequestUriEvent { @@ -25,135 +20,106 @@ pub enum RequestUriEvent { SelectNext, /// requests the parent to select the previous pane SelectPrev, - /// user pressed `C-c` hotkey so we bubble up the event for the parent to handle - Quit, } #[derive(Debug)] -pub struct RequestUri<'ru> { - colors: &'ru hac_colors::Colors, - collection_store: Rc>, +pub struct RequestUri { + colors: HacColors, size: Rect, + focused: bool, + selected: bool, } -impl<'ru> RequestUri<'ru> { - pub fn new( - colors: &'ru hac_colors::Colors, - collection_store: Rc>, - size: Rect, - ) -> Self { +impl RequestUri { + pub fn new(colors: HacColors, size: Rect) -> Self { Self { colors, - collection_store, size, + focused: false, + selected: false, } } + + pub fn focus(&mut self) { + self.focused = true; + } + + pub fn blur(&mut self) { + self.focused = false; + } + + pub fn select(&mut self) { + self.selected = true; + } + + pub fn deselect(&mut self) { + self.selected = false; + } } -impl<'a> Renderable for RequestUri<'a> { +impl Renderable for RequestUri { + type Input = (); + type Output = (); + + fn data(&self, _requester: u8) -> Self::Output {} + fn resize(&mut self, new_size: Rect) { self.size = new_size; } fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()> { - let is_focused = self - .collection_store - .borrow() - .get_focused_pane() - .eq(&PaneFocus::ReqUri); - let is_selected = self - .collection_store - .borrow() - .get_selected_pane() - .is_some_and(|pane| pane.eq(&PaneFocus::ReqUri)); - - let block_border = match (is_focused, is_selected) { + let block_border = match (self.focused, self.selected) { (true, false) => Style::default().fg(self.colors.bright.blue), (true, true) => Style::default().fg(self.colors.normal.red), (false, _) => Style::default().fg(self.colors.bright.black), }; - let uri = self - .collection_store - .borrow() - .get_selected_request() - .as_ref() - .map(|req| req.read().unwrap().uri.to_string()) - .unwrap_or_default(); - - frame.render_widget( - Paragraph::new(uri).fg(self.colors.normal.white).block( - Block::default() - .borders(Borders::ALL) - .border_style(block_border) - .title(vec![ - "U".fg(self.colors.normal.red).bold(), - "ri".fg(self.colors.bright.black), - ]), - ), - size, - ); + let uri = hac_store::collection::get_selected_request(|req| Some(req.uri.to_string())).unwrap_or_default(); + let len = uri.chars().count() as u16; + + let uri = Paragraph::new(uri) + .fg(self.colors.normal.white) + .block(Block::default().borders(Borders::ALL).border_style(block_border)); + + frame.render_widget(uri, size); + + if self.selected { + let x = self.size.x + len + 1; + frame.set_cursor(x, self.size.y + 1); + } Ok(()) } } -impl Eventful for RequestUri<'_> { +impl Eventful for RequestUri { type Result = RequestUriEvent; fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { - let is_selected = self - .collection_store - .borrow() - .get_selected_pane() - .is_some_and(|pane| pane.eq(&PaneFocus::ReqUri)); - - assert!( - is_selected, - "handled an event to the request uri while it was not selected" - ); - - if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { - return Ok(Some(RequestUriEvent::Quit)); - } + assert!(self.selected); match key_event.code { KeyCode::Esc => return Ok(Some(RequestUriEvent::RemoveSelection)), KeyCode::Tab => return Ok(Some(RequestUriEvent::SelectNext)), KeyCode::BackTab => return Ok(Some(RequestUriEvent::SelectPrev)), - KeyCode::Char(c) => { - if let Some(req) = self - .collection_store - .borrow() - .get_selected_request() - .as_mut() - { - req.write().unwrap().uri.push(c); - } - } - KeyCode::Backspace => { - if let Some(req) = self - .collection_store - .borrow() - .get_selected_request() - .as_mut() - { - req.write().unwrap().uri.pop(); - } - } - KeyCode::Enter => { - let mut store = self.collection_store.borrow_mut(); - if store - .get_selected_request() - .as_ref() - .is_some_and(|_| !store.has_pending_request()) - { - store.dispatch(CollectionStoreAction::SetPendingRequest(true)); - return Ok(Some(RequestUriEvent::SendRequest)); - } + KeyCode::Backspace => hac_store::collection::get_selected_request_mut(|req| _ = req.uri.pop()), + KeyCode::Char('w') if matches!(key_event.modifiers, KeyModifiers::CONTROL) => { + hac_store::collection::get_selected_request_mut(|req| { + let len = req.uri.len().saturating_sub(1); + let position = req + .uri + .chars() + .rev() + .skip(1) + .position(|ch| !matches!(ch, 'a'..='z' | 'A'..='Z' | '0'..='9')) + .unwrap_or(len); + req.uri.truncate(len - position); + }) } + KeyCode::Char(c) => hac_store::collection::get_selected_request_mut(|req| req.uri.push(c)), + KeyCode::Enter => return Ok(Some(RequestUriEvent::SendRequest)), _ => {} - } + }; Ok(None) } diff --git a/hac-client/src/pages/collection_viewer/response_viewer.rs b/hac-client/src/pages/collection_viewer/response_viewer.rs index 2622ef1..42cb96d 100755 --- a/hac-client/src/pages/collection_viewer/response_viewer.rs +++ b/hac-client/src/pages/collection_viewer/response_viewer.rs @@ -1,33 +1,26 @@ -use hac_core::net::request_manager::Response; -use hac_core::syntax::highlighter::HIGHLIGHTER; - -use crate::ascii::{BIG_ERROR_ARTS, LOGO_ASCII, SMALL_ERROR_ARTS}; -use crate::pages::collection_viewer::collection_viewer::PaneFocus; -use crate::pages::under_construction::UnderConstruction; -use crate::pages::{spinner::Spinner, Eventful, Renderable}; -use crate::utils::build_syntax_highlighted_lines; - -use std::cell::RefCell; use std::iter; use std::ops::{Add, Sub}; -use std::rc::Rc; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use rand::Rng; +use crate::HacColors; + +use crate::components::spinner::Spinner; +use crossterm::event::{KeyCode, KeyEvent}; +use hac_core::net::request_manager::Response; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Style, Stylize}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Scrollbar}; -use ratatui::widgets::{ScrollbarOrientation, ScrollbarState, Tabs}; +use ratatui::widgets::{ + Block, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Tabs, +}; use ratatui::Frame; use tree_sitter::Tree; -use super::collection_store::CollectionStore; +use crate::ascii::{BIG_ERROR_ART, SMALL_ERROR_ART}; +use crate::renderable::{Eventful, Renderable}; #[derive(Debug)] pub enum ResponseViewerEvent { RemoveSelection, - Quit, } #[derive(Debug, Clone, PartialEq)] @@ -82,53 +75,37 @@ struct PreviewLayout { scrollbar: Rect, } -#[derive(Debug, Clone)] -pub struct ResponseViewer<'a> { - colors: &'a hac_colors::Colors, - response: Option>>, +#[derive(Debug)] +pub struct ResponseViewer { + colors: HacColors, + response: Option, tree: Option, lines: Vec>, error_lines: Option>>, - empty_lines: Vec>, preview_layout: PreviewLayout, layout: ResViewerLayout, - collection_store: Rc>, active_tab: ResViewerTabs, raw_scroll: usize, headers_scroll_y: usize, headers_scroll_x: usize, pretty_scroll: usize, -} -impl<'a> ResponseViewer<'a> { - pub fn new( - colors: &'a hac_colors::Colors, - collection_store: Rc>, - response: Option>>, - size: Rect, - ) -> Self { - let tree = response.as_ref().and_then(|response| { - if let Some(ref pretty_body) = response.borrow().pretty_body { - let pretty_body = pretty_body.to_string(); - let mut highlighter = HIGHLIGHTER.write().unwrap(); - highlighter.parse(&pretty_body) - } else { - None - } - }); + pub pending_request: bool, + focused: bool, + selected: bool, +} +impl ResponseViewer { + pub fn new(colors: HacColors, size: Rect) -> Self { let layout = build_layout(size); let preview_layout = build_preview_layout(layout.content_pane); - let empty_lines = make_empty_ascii_art(colors); - ResponseViewer { colors, - response, - tree, + response: None, + tree: None, lines: vec![], error_lines: None, - empty_lines, preview_layout, layout, active_tab: ResViewerTabs::Preview, @@ -136,94 +113,82 @@ impl<'a> ResponseViewer<'a> { headers_scroll_y: 0, headers_scroll_x: 0, pretty_scroll: 0, - collection_store, + + pending_request: false, + focused: false, + selected: false, } } + pub fn focus(&mut self) { + self.focused = true; + } + + pub fn blur(&mut self) { + self.focused = false; + } + + pub fn select(&mut self) { + self.selected = true; + } + + pub fn deselect(&mut self) { + self.selected = false; + } + pub fn resize(&mut self, new_size: Rect) { self.layout = build_layout(new_size); self.preview_layout = build_preview_layout(self.layout.content_pane); } - pub fn update(&mut self, response: Option>>) { - let body_str = response - .as_ref() - .and_then(|res| { - res.borrow() - .pretty_body - .as_ref() - .map(|body| body.to_string()) - }) - .unwrap_or_default(); - - if body_str.len().gt(&0) { - self.tree = HIGHLIGHTER.write().unwrap().parse(&body_str); - self.lines = build_syntax_highlighted_lines(&body_str, self.tree.as_ref(), self.colors); - } else { - self.tree = None; - self.lines = vec![]; - } + pub fn update(&mut self, response: Option) { + //let body_str = response + // .as_ref() + // .and_then(|res| res.pretty_body.as_ref().map(|body| body.to_string())) + // .unwrap_or_default(); + + //if body_str.len().gt(&0) { + // self.tree = HIGHLIGHTER.write().unwrap().parse(&body_str); + // self.lines = build_syntax_highlighted_lines(&body_str, self.tree.as_ref(), self.colors); + //} else { + // self.tree = None; + // self.lines = vec![]; + //} if let Some(res) = response.as_ref() { let cause: String = res - .borrow() .cause .as_ref() .map(|cause| cause.to_string()) .unwrap_or(String::default()); self.error_lines = Some( - get_error_ascii_art( - self.preview_layout.content_pane.width, - &mut rand::thread_rng(), - ) - .iter() - .map(|line| Line::from(line.to_string()).centered()) - .chain(vec!["".into()]) - .chain( - cause - .chars() - .collect::>() - .chunks(self.layout.content_pane.width.sub(3).into()) - .map(|chunk| { - Line::from(chunk.iter().collect::().fg(self.colors.normal.red)) - }) - .collect::>(), - ) - .collect::>(), + get_error_ascii_art(self.preview_layout.content_pane.width) + .iter() + .map(|line| Line::from(line.to_string()).centered()) + .chain(vec!["".into()]) + .chain( + cause + .chars() + .collect::>() + .chunks(self.layout.content_pane.width.sub(3).into()) + .map(|chunk| Line::from(chunk.iter().collect::().fg(self.colors.normal.red))) + .collect::>(), + ) + .collect::>(), ) }; - self.empty_lines = make_empty_ascii_art(self.colors); self.response = response; } fn draw_container(&self, size: Rect, frame: &mut Frame) { - let is_focused = self - .collection_store - .borrow() - .get_focused_pane() - .eq(&PaneFocus::Preview); - let is_selected = self - .collection_store - .borrow() - .get_selected_pane() - .is_some_and(|pane| pane.eq(&PaneFocus::Preview)); - - let block_border = match (is_focused, is_selected) { + let block_border = match (self.focused, self.selected) { (true, false) => Style::default().fg(self.colors.bright.blue), (true, true) => Style::default().fg(self.colors.normal.red), (_, _) => Style::default().fg(self.colors.bright.black), }; - - let block = Block::default() - .borders(Borders::ALL) - .title(vec![ - "P".fg(self.colors.normal.red).bold(), - "review".fg(self.colors.bright.black), - ]) - .border_style(block_border); - + let block = Block::default().borders(Borders::ALL).border_style(block_border); frame.render_widget(block, size); } @@ -249,10 +214,7 @@ impl<'a> ResponseViewer<'a> { .into_centered_line(); frame.render_widget(Clear, request_pane); - frame.render_widget( - Block::default().bg(self.colors.primary.background), - request_pane, - ); + frame.render_widget(Block::default().bg(self.colors.primary.background), request_pane); frame.render_widget(spinner, size); } @@ -261,10 +223,7 @@ impl<'a> ResponseViewer<'a> { let request_pane = self.preview_layout.content_pane; frame.render_widget(Clear, request_pane); - frame.render_widget( - Block::default().bg(self.colors.primary.background), - request_pane, - ); + frame.render_widget(Block::default().bg(self.colors.primary.background), request_pane); let center = request_pane .y @@ -285,88 +244,30 @@ impl<'a> ResponseViewer<'a> { } } - fn draw_waiting_for_request(&self, frame: &mut Frame) { - let request_pane = self.preview_layout.content_pane; - frame.render_widget(Clear, request_pane); - frame.render_widget( - Block::default().bg(self.colors.primary.background), - request_pane, - ); - - let mut empty_message = self.empty_lines.clone(); - - if self.empty_lines.len() >= request_pane.height.into() { - empty_message = vec![ - "your handy API client".fg(self.colors.normal.red).into(), - "".into(), - "make a request and the result will appear here" - .fg(self.colors.normal.red) - .into(), - ]; - } - - let center = request_pane - .y - .add(request_pane.height.div_ceil(2)) - .sub(empty_message.len().div_ceil(2) as u16); - - let size = Rect::new( - request_pane.x.add(1), - center, - request_pane.width, - self.empty_lines.len() as u16, - ); - - frame.render_widget( - Paragraph::new(empty_message) - .fg(self.colors.normal.red) - .centered(), - size, - ) - } - fn draw_current_tab(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()> { - if self - .response - .as_ref() - .is_some_and(|res| res.borrow().is_error) - { + if self.response.as_ref().is_some_and(|res| res.is_error) { self.draw_network_error(frame); }; - if self.response.is_none() { - self.draw_waiting_for_request(frame); - } - - if self - .response - .as_ref() - .is_some_and(|res| !res.borrow().is_error) - { + if self.response.as_ref().is_some_and(|res| !res.is_error) { match self.active_tab { ResViewerTabs::Preview => self.draw_pretty_response(frame, size), ResViewerTabs::Raw => self.draw_raw_response(frame, size), ResViewerTabs::Headers => self.draw_response_headers(frame), - ResViewerTabs::Cookies => UnderConstruction::new(self.colors).draw(frame, size)?, + ResViewerTabs::Cookies => (), } } - if self.collection_store.borrow().has_pending_request() { - self.draw_spinner(frame); - } - Ok(()) } fn draw_response_headers(&mut self, frame: &mut Frame) { if let Some(response) = self.response.as_ref() { - if let Some(headers) = response.borrow().headers.as_ref() { + if let Some(headers) = response.headers.as_ref() { let mut longest_line: usize = 0; - let mut lines: Vec = vec![ - Line::from("Headers".fg(self.colors.normal.red).bold()), - Line::from(""), - ]; + let mut lines: Vec = + vec![Line::from("Headers".fg(self.colors.normal.red).bold()), Line::from("")]; for (name, value) in headers { if let Ok(value) = value.to_str() { @@ -382,10 +283,7 @@ impl<'a> ResponseViewer<'a> { .yellow(), )); lines.push(Line::from( - value - .chars() - .skip(self.headers_scroll_x) - .collect::(), + value.chars().skip(self.headers_scroll_x).collect::(), )); lines.push(Line::from("")); } @@ -403,21 +301,14 @@ impl<'a> ResponseViewer<'a> { self.headers_scroll_x = longest_line.saturating_sub(1); } - let [headers_pane, x_scrollbar_pane] = - build_horizontal_scrollbar(self.preview_layout.content_pane); - self.draw_scrollbar( - lines.len(), - self.headers_scroll_y, - frame, - self.preview_layout.scrollbar, - ); - - let lines_to_show = - if longest_line > self.preview_layout.content_pane.width as usize { - headers_pane.height - } else { - self.preview_layout.content_pane.height - }; + let [headers_pane, x_scrollbar_pane] = build_horizontal_scrollbar(self.preview_layout.content_pane); + self.draw_scrollbar(lines.len(), self.headers_scroll_y, frame, self.preview_layout.scrollbar); + + let lines_to_show = if longest_line > self.preview_layout.content_pane.width as usize { + headers_pane.height + } else { + self.preview_layout.content_pane.height + }; let lines = lines .into_iter() @@ -428,18 +319,10 @@ impl<'a> ResponseViewer<'a> { let block = Block::default().padding(Padding::left(1)); if longest_line > self.preview_layout.content_pane.width as usize { - self.draw_horizontal_scrollbar( - longest_line, - self.headers_scroll_x, - frame, - x_scrollbar_pane, - ); + self.draw_horizontal_scrollbar(longest_line, self.headers_scroll_x, frame, x_scrollbar_pane); frame.render_widget(Paragraph::new(lines).block(block), headers_pane) } else { - frame.render_widget( - Paragraph::new(lines).block(block), - self.preview_layout.content_pane, - ); + frame.render_widget(Paragraph::new(lines).block(block), self.preview_layout.content_pane); } } } @@ -447,9 +330,8 @@ impl<'a> ResponseViewer<'a> { fn draw_raw_response(&mut self, frame: &mut Frame, size: Rect) { if let Some(response) = self.response.as_ref() { - let lines = if response.borrow().body.is_some() { + let lines = if response.body.is_some() { response - .borrow() .body .as_ref() .unwrap() @@ -467,12 +349,7 @@ impl<'a> ResponseViewer<'a> { self.raw_scroll = lines.len().saturating_sub(1); } - self.draw_scrollbar( - lines.len(), - self.raw_scroll, - frame, - self.preview_layout.scrollbar, - ); + self.draw_scrollbar(lines.len(), self.raw_scroll, frame, self.preview_layout.scrollbar); let lines_in_view = lines .into_iter() @@ -486,13 +363,7 @@ impl<'a> ResponseViewer<'a> { } } - fn draw_scrollbar( - &self, - total_lines: usize, - current_scroll: usize, - frame: &mut Frame, - size: Rect, - ) { + fn draw_scrollbar(&self, total_lines: usize, current_scroll: usize, frame: &mut Frame, size: Rect) { let mut scrollbar_state = ScrollbarState::new(total_lines).position(current_scroll); let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) @@ -503,13 +374,7 @@ impl<'a> ResponseViewer<'a> { frame.render_stateful_widget(scrollbar, size, &mut scrollbar_state); } - fn draw_horizontal_scrollbar( - &self, - total_columns: usize, - current_scroll: usize, - frame: &mut Frame, - size: Rect, - ) { + fn draw_horizontal_scrollbar(&self, total_columns: usize, current_scroll: usize, frame: &mut Frame, size: Rect) { let mut scrollbar_state = ScrollbarState::new(total_columns).position(current_scroll); let scrollbar = Scrollbar::new(ScrollbarOrientation::HorizontalBottom) @@ -520,50 +385,37 @@ impl<'a> ResponseViewer<'a> { frame.render_stateful_widget(scrollbar, size, &mut scrollbar_state); } - fn draw_pretty_response(&mut self, frame: &mut Frame, size: Rect) { - if self.response.as_ref().is_some() { - if self.pretty_scroll.ge(&self.lines.len().saturating_sub(1)) { - self.pretty_scroll = self.lines.len().saturating_sub(1); - } - - self.draw_scrollbar( - self.lines.len(), - self.raw_scroll, - frame, - self.preview_layout.scrollbar, - ); - - let lines = if self.lines.len().gt(&0) { - self.lines.clone() - } else { - vec![Line::from("No body").centered()] - }; - - let lines_in_view = lines - .into_iter() - .skip(self.pretty_scroll) - .chain(iter::repeat(Line::from("~".fg(self.colors.bright.black)))) - .take(size.height.into()) - .collect::>(); - - let pretty_response = Paragraph::new(lines_in_view); - frame.render_widget(pretty_response, self.preview_layout.content_pane); - } + fn draw_pretty_response(&mut self, _frame: &mut Frame, _size: Rect) { + //if self.response.as_ref().is_some() { + // if self.pretty_scroll.ge(&self.lines.len().saturating_sub(1)) { + // self.pretty_scroll = self.lines.len().saturating_sub(1); + // } + // + // self.draw_scrollbar(self.lines.len(), self.raw_scroll, frame, self.preview_layout.scrollbar); + // + // let lines = + // if self.lines.len().gt(&0) { self.lines.clone() } else { vec![Line::from("No body").centered()] }; + // + // let lines_in_view = lines + // .into_iter() + // .skip(self.pretty_scroll) + // .chain(iter::repeat(Line::from("~".fg(self.colors.bright.black)))) + // .take(size.height.into()) + // .collect::>(); + // + // let pretty_response = Paragraph::new(lines_in_view); + // frame.render_widget(pretty_response, self.preview_layout.content_pane); + //} } fn draw_summary(&self, frame: &mut Frame, size: Rect) { if let Some(ref response) = self.response { - let status_color = match response - .borrow() - .status - .map(|status| status.as_u16()) - .unwrap_or_default() - { + let status_color = match response.status.map(|status| status.as_u16()).unwrap_or_default() { s if s < 400 => self.colors.normal.green, _ => self.colors.normal.red, }; - let status = match response.borrow().status { + let status = match response.status { Some(status) if size.width.gt(&50) => format!( "{} ({})", status.as_str(), @@ -581,12 +433,11 @@ impl<'a> ResponseViewer<'a> { status, " ".into(), "Time: ".fg(self.colors.bright.black), - format!("{}ms", response.borrow().duration.as_millis()) - .fg(self.colors.normal.green), + format!("{}ms", response.duration.as_millis()).fg(self.colors.normal.green), " ".into(), ]; - if let Some(size) = response.borrow().size { + if let Some(size) = response.size { pieces.push("Size: ".fg(self.colors.bright.black)); pieces.push(format!("{} B", size).fg(self.colors.normal.green)) }; @@ -596,8 +447,17 @@ impl<'a> ResponseViewer<'a> { } } -impl<'a> Renderable for ResponseViewer<'a> { +impl Renderable for ResponseViewer { + type Input = (); + type Output = (); + + fn data(&self, _: u8) -> Self::Output {} + fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()> { + if self.pending_request { + self.draw_spinner(frame); + } + self.draw_tabs(frame, self.layout.tabs_pane); self.draw_current_tab(frame, self.layout.content_pane)?; self.draw_summary(frame, self.layout.summary_pane); @@ -609,18 +469,10 @@ impl<'a> Renderable for ResponseViewer<'a> { fn resize(&mut self, _new_size: Rect) {} } -impl<'a> Eventful for ResponseViewer<'a> { +impl Eventful for ResponseViewer { type Result = ResponseViewerEvent; fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { - if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { - return Ok(Some(ResponseViewerEvent::Quit)); - } - - if let KeyCode::Esc = key_event.code { - return Ok(Some(ResponseViewerEvent::RemoveSelection)); - } - if let KeyCode::Tab = key_event.code { self.active_tab = ResViewerTabs::next(&self.active_tab); } @@ -629,6 +481,10 @@ impl<'a> Eventful for ResponseViewer<'a> { self.active_tab = ResViewerTabs::prev(&self.active_tab); } + if let KeyCode::Esc = key_event.code { + return Ok(Some(ResponseViewerEvent::RemoveSelection)); + } + match key_event.code { KeyCode::Char('0') if self.active_tab.eq(&ResViewerTabs::Headers) => { self.headers_scroll_x = 0; @@ -650,9 +506,7 @@ impl<'a> Eventful for ResponseViewer<'a> { KeyCode::Char('k') => match self.active_tab { ResViewerTabs::Preview => self.pretty_scroll = self.pretty_scroll.saturating_sub(1), ResViewerTabs::Raw => self.raw_scroll = self.raw_scroll.saturating_sub(1), - ResViewerTabs::Headers => { - self.headers_scroll_y = self.headers_scroll_y.saturating_sub(1) - } + ResViewerTabs::Headers => self.headers_scroll_y = self.headers_scroll_y.saturating_sub(1), ResViewerTabs::Cookies => {} }, KeyCode::Char('l') => { @@ -662,7 +516,6 @@ impl<'a> Eventful for ResponseViewer<'a> { } _ => {} } - Ok(None) } } @@ -695,11 +548,7 @@ fn build_layout(size: Rect) -> ResViewerLayout { fn build_horizontal_scrollbar(size: Rect) -> [Rect; 2] { let [request_pane, _, scrollbar_pane] = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Fill(1), - Constraint::Length(1), - Constraint::Length(1), - ]) + .constraints([Constraint::Fill(1), Constraint::Length(1), Constraint::Length(1)]) .areas(size); [request_pane, scrollbar_pane] @@ -708,11 +557,7 @@ fn build_horizontal_scrollbar(size: Rect) -> [Rect; 2] { fn build_preview_layout(size: Rect) -> PreviewLayout { let [content_pane, _, scrollbar] = Layout::default() .direction(Direction::Horizontal) - .constraints([ - Constraint::Fill(1), - Constraint::Length(1), - Constraint::Length(1), - ]) + .constraints([Constraint::Fill(1), Constraint::Length(1), Constraint::Length(1)]) .areas(size); PreviewLayout { @@ -721,75 +566,9 @@ fn build_preview_layout(size: Rect) -> PreviewLayout { } } -fn get_error_ascii_art(width: u16, rng: &mut R) -> &'static [&'static str] -where - R: Rng, -{ +fn get_error_ascii_art(width: u16) -> &'static [&'static str] { match width.gt(&60) { - false => { - let index = rng.gen_range(0..SMALL_ERROR_ARTS.len()); - SMALL_ERROR_ARTS[index] - } - true => { - let full_range_arts = BIG_ERROR_ARTS - .iter() - .chain(SMALL_ERROR_ARTS) - .collect::>(); - let index = rng.gen_range(0..full_range_arts.len()); - full_range_arts[index] - } - } -} - -fn make_empty_ascii_art(colors: &hac_colors::Colors) -> Vec> { - LOGO_ASCII[0] - .iter() - .map(|line| line.to_string().into()) - .chain(vec![ - "".into(), - "your handy API client".fg(colors.bright.blue).into(), - "".into(), - "".into(), - "make a request and the result will appear here" - .fg(colors.bright.black) - .into(), - ]) - .collect::>() -} - -#[cfg(test)] -mod tests { - use rand::{rngs::StdRng, SeedableRng}; - - use super::*; - #[test] - fn test_ascii_with_size() { - let seed = [0u8; 32]; - let mut rng = StdRng::from_seed(seed); - - let too_small = 59; - let art = get_error_ascii_art(too_small, &mut rng); - - let expected = [ - r#" ____ ____ ____ ___ ____ "#, - r#" / _ )/ ___) ___) _ \ / ___)"#, - r#"( (/ /| | | | | |_| | | "#, - r#" \____)_| |_| \___/|_| "#, - ]; - - assert_eq!(art, expected); - - let expected = [ - r#" dBBBP dBBBBBb dBBBBBb dBBBBP dBBBBBb"#, - r#" dBP dBP dBP.BP dBP"#, - r#" dBBP dBBBBK dBBBBK dBP.BP dBBBBK "#, - r#" dBP dBP BB dBP BB dBP.BP dBP BB "#, - r#" dBBBBP dBP dB' dBP dB' dBBBBP dBP dB' "#, - ]; - - let big_enough = 100; - let art = get_error_ascii_art(big_enough, &mut rng); - - assert_eq!(art, expected); + false => SMALL_ERROR_ART, + true => BIG_ERROR_ART, } } diff --git a/hac-client/src/pages/collection_viewer/sidebar.rs b/hac-client/src/pages/collection_viewer/sidebar.rs deleted file mode 100755 index e34ac49..0000000 --- a/hac-client/src/pages/collection_viewer/sidebar.rs +++ /dev/null @@ -1,541 +0,0 @@ -mod create_directory_form; -mod create_request_form; -mod delete_item_prompt; -mod directory_form; -mod edit_directory_form; -mod edit_request_form; -mod request_form; -mod select_request_parent; - -use hac_core::collection::types::{Request, RequestKind, RequestMethod}; - -use super::sidebar::delete_item_prompt::{DeleteItemPrompt, DeleteItemPromptEvent}; -use super::sidebar::directory_form::{DirectoryForm, DirectoryFormEvent}; -use super::sidebar::directory_form::{DirectoryFormCreate, DirectoryFormEdit}; -use super::sidebar::request_form::{RequestForm, RequestFormEvent}; -use super::sidebar::request_form::{RequestFormCreate, RequestFormEdit}; -use crate::pages::collection_viewer::collection_store::{CollectionStore, CollectionStoreAction}; -use crate::pages::collection_viewer::collection_viewer::{CollectionViewerOverlay, PaneFocus}; -use crate::pages::{Eventful, Renderable}; - -use std::cell::RefCell; -use std::collections::HashMap; -use std::rc::Rc; -use std::sync::{Arc, RwLock}; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use ratatui::layout::Rect; -use ratatui::style::{Style, Styled, Stylize}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; -use ratatui::Frame; - -/// set of events Sidebar can emit to the caller when handling events. -#[derive(Debug, PartialEq, Eq, Clone)] -pub enum SidebarEvent { - /// user pressed `CreateRequest (n)` hotkey, which should notify the caller to open - /// the create_request_form and properly handle the creation of a new request - CreateRequest, - /// user pressed `Edit (e)` hotkey on a request, which should notify the caller to open - /// the edit_request_form and properly handle the editing of the existing request - EditRequest, - /// user pressed `Edit (e)` hotkey on a directory, which should notify the caller to - /// open the `edit_request_form` and properly handle the editing of the current directory - EditDirectory, - /// user pressed `CreateDirectory (d)` hotkey, which should notify the caller to open - /// the `create_directory_form` overlay to create a new directory on the collection - CreateDirectory, - /// user pressed `Esc` so we notify the caller to remove the selection from - /// this pane, essentially bubbling the key handling scope to the caller - RemoveSelection, - /// tells the parent to move selection to the next pane - SelectNext, - /// tells the parent to move selection to the previous pane - SelectPrev, - /// event to force a full rebuild of the view, when a request is deleted - RebuildView, - /// this event is used when a request or directory is created, this notify the parent - /// to sync changes with the file system. - SyncCollection, - /// user pressed `DeleteItem (D)` hotkey, which should notify the caller to open the - /// delete_item_prompt to ask the user for confirmation - DeleteItem(String), - /// user pressed a hotkey to quit the application, so we bubble up so the caller - /// can do a few things before bubbling the quit request further up - Quit, -} - -#[derive(Debug)] -enum DirectoryFormVariant<'sbar> { - Create(DirectoryForm<'sbar, DirectoryFormCreate>), - Edit(DirectoryForm<'sbar, DirectoryFormEdit>), -} - -impl DirectoryFormVariant<'_> { - pub fn inner(&mut self) -> &mut dyn DirectoryFormTrait { - match self { - DirectoryFormVariant::Create(form) => form, - DirectoryFormVariant::Edit(form) => form, - } - } -} - -#[derive(Debug)] -enum RequestFormVariant<'sbar> { - Create(RequestForm<'sbar, RequestFormCreate>), - Edit(RequestForm<'sbar, RequestFormEdit>), -} - -/// this is just a helper trait to be able to return the inner reference of the form -/// from the enum as we cannot return it like: -/// `&mut dyn Renderable + Eventful;` -pub trait DirectoryFormTrait: Renderable + Eventful {} - -/// this is just a helper trait to be able to return the inner reference of the form -/// from the enum as we cannot return it like: -/// `&mut dyn Renderable + Eventful;` -#[allow(unused_variables)] -pub trait RequestFormTrait: Renderable + Eventful { - fn draw_overlay( - &mut self, - frame: &mut Frame, - overlay: CollectionViewerOverlay, - ) -> anyhow::Result<()> { - Ok(()) - } -} - -impl RequestFormVariant<'_> { - pub fn inner(&mut self) -> &mut dyn RequestFormTrait { - match self { - RequestFormVariant::Create(form) => form, - RequestFormVariant::Edit(form) => form, - } - } -} - -#[derive(Debug)] -pub struct Sidebar<'sbar> { - colors: &'sbar hac_colors::Colors, - lines: Vec>, - collection_store: Rc>, - request_form: RequestFormVariant<'sbar>, - directory_form: DirectoryFormVariant<'sbar>, - delete_item_prompt: DeleteItemPrompt<'sbar>, -} - -impl<'sbar> Sidebar<'sbar> { - pub fn new( - colors: &'sbar hac_colors::Colors, - collection_store: Rc>, - ) -> Self { - let mut sidebar = Self { - colors, - request_form: RequestFormVariant::Create(RequestForm::::new( - colors, - collection_store.clone(), - )), - directory_form: DirectoryFormVariant::Create( - DirectoryForm::::new(colors, collection_store.clone()), - ), - delete_item_prompt: DeleteItemPrompt::new(colors, collection_store.clone()), - lines: vec![], - collection_store, - }; - - sidebar.rebuild_tree_view(); - - sidebar - } - - pub fn rebuild_tree_view(&mut self) { - let mut collection_store = self.collection_store.borrow_mut(); - self.lines = build_lines( - collection_store.get_requests(), - 0, - collection_store.get_selected_request(), - collection_store.get_hovered_request(), - collection_store.get_dirs_expanded().unwrap().clone(), - self.colors, - ); - } - - pub fn draw_overlay( - &mut self, - frame: &mut Frame, - overlay: CollectionViewerOverlay, - ) -> anyhow::Result<()> { - match overlay { - CollectionViewerOverlay::CreateRequest => { - self.request_form.inner().draw(frame, frame.size())?; - } - CollectionViewerOverlay::EditRequest => { - self.request_form.inner().draw(frame, frame.size())?; - } - CollectionViewerOverlay::SelectParentDir => { - self.request_form.inner().draw_overlay(frame, overlay)?; - } - CollectionViewerOverlay::CreateDirectory => { - self.directory_form.inner().draw(frame, frame.size())?; - } - CollectionViewerOverlay::EditDirectory => { - self.directory_form.inner().draw(frame, frame.size())?; - } - CollectionViewerOverlay::DeleteSidebarItem(_) => { - self.delete_item_prompt.draw(frame, frame.size())?; - } - _ => {} - }; - - Ok(()) - } -} - -impl<'sbar> Renderable for Sidebar<'sbar> { - fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()> { - let is_focused = self - .collection_store - .borrow() - .get_focused_pane() - .eq(&PaneFocus::Sidebar); - let is_selected = self - .collection_store - .borrow() - .get_selected_pane() - .is_some_and(|pane| pane.eq(&PaneFocus::Sidebar)); - - let mut requests_size = Rect::new(size.x + 1, size.y, size.width.saturating_sub(2), 1); - - let block_border = match (is_focused, is_selected) { - (true, false) => Style::default().fg(self.colors.bright.blue), - (true, true) => Style::default().fg(self.colors.normal.red), - (false, _) => Style::default().fg(self.colors.bright.black), - }; - - let block = Block::default() - .borders(Borders::ALL) - .title(vec![ - "R".fg(self.colors.normal.red).bold(), - "equests".fg(self.colors.bright.black), - ]) - .border_style(block_border); - - frame.render_widget(block, size); - - self.lines.clone().into_iter().for_each(|req| { - requests_size.y += 1; - frame.render_widget(req, requests_size); - }); - - Ok(()) - } - - fn resize(&mut self, _new_size: Rect) {} -} - -impl<'a> Eventful for Sidebar<'a> { - type Result = SidebarEvent; - - fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { - let is_selected = self - .collection_store - .borrow() - .get_selected_pane() - .is_some_and(|pane| pane.eq(&PaneFocus::Sidebar)); - assert!( - is_selected, - "handled an event to the sidebar while it was not selected" - ); - - let overlay = self.collection_store.borrow_mut().peek_overlay(); - - match overlay { - CollectionViewerOverlay::CreateRequest => { - match self.request_form.inner().handle_key_event(key_event)? { - Some(RequestFormEvent::Confirm) => { - let mut store = self.collection_store.borrow_mut(); - store.pop_overlay(); - drop(store); - self.rebuild_tree_view(); - return Ok(Some(SidebarEvent::SyncCollection)); - } - Some(RequestFormEvent::Cancel) => { - let mut store = self.collection_store.borrow_mut(); - store.pop_overlay(); - drop(store); - self.rebuild_tree_view(); - return Ok(None); - } - None => return Ok(None), - } - } - CollectionViewerOverlay::SelectParentDir => { - let result = self.request_form.inner().handle_key_event(key_event)?; - assert!( - result.is_none(), - "should never return an event when selecting parent dir" - ); - return Ok(None); - } - CollectionViewerOverlay::CreateDirectory => { - match self.directory_form.inner().handle_key_event(key_event)? { - Some(DirectoryFormEvent::Confirm) => { - let mut store = self.collection_store.borrow_mut(); - store.pop_overlay(); - drop(store); - self.rebuild_tree_view(); - return Ok(Some(SidebarEvent::SyncCollection)); - } - Some(DirectoryFormEvent::Cancel) => { - let mut store = self.collection_store.borrow_mut(); - store.pop_overlay(); - drop(store); - self.rebuild_tree_view(); - return Ok(None); - } - None => return Ok(None), - } - } - CollectionViewerOverlay::EditDirectory => { - match self.directory_form.inner().handle_key_event(key_event)? { - Some(DirectoryFormEvent::Confirm) => { - let mut store = self.collection_store.borrow_mut(); - store.pop_overlay(); - drop(store); - self.rebuild_tree_view(); - return Ok(Some(SidebarEvent::SyncCollection)); - } - Some(DirectoryFormEvent::Cancel) => { - let mut store = self.collection_store.borrow_mut(); - store.pop_overlay(); - drop(store); - self.rebuild_tree_view(); - return Ok(None); - } - None => return Ok(None), - } - } - CollectionViewerOverlay::EditRequest => { - // when editing, we setup the form to display the current header information. - match self.request_form.inner().handle_key_event(key_event)? { - Some(RequestFormEvent::Confirm) => { - let mut store = self.collection_store.borrow_mut(); - store.pop_overlay(); - drop(store); - self.rebuild_tree_view(); - return Ok(Some(SidebarEvent::SyncCollection)); - } - Some(RequestFormEvent::Cancel) => { - let mut store = self.collection_store.borrow_mut(); - store.pop_overlay(); - drop(store); - self.rebuild_tree_view(); - return Ok(None); - } - None => return Ok(None), - } - } - CollectionViewerOverlay::DeleteSidebarItem(item_id) => { - match self.delete_item_prompt.handle_key_event(key_event)? { - Some(DeleteItemPromptEvent::Confirm) => { - let mut store = self.collection_store.borrow_mut(); - let changed_selection = store - .get_selected_request() - .is_some_and(|req| req.read().unwrap().id.eq(&item_id)); - store.remove_item(item_id); - store.pop_overlay(); - drop(store); - self.rebuild_tree_view(); - - if changed_selection { - return Ok(Some(SidebarEvent::RebuildView)); - } else { - return Ok(None); - } - } - Some(DeleteItemPromptEvent::Cancel) => { - let mut store = self.collection_store.borrow_mut(); - store.pop_overlay(); - drop(store); - self.rebuild_tree_view(); - return Ok(None); - } - None => return Ok(None), - } - } - _ => {} - }; - - if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { - return Ok(Some(SidebarEvent::Quit)); - } - - let mut store = self.collection_store.borrow_mut(); - - match key_event.code { - KeyCode::Enter => { - if store.get_requests().is_none() || store.get_hovered_request().is_none() { - return Ok(None); - } - - let request = store.find_hovered_request(); - match request { - RequestKind::Nested(_) => { - store.dispatch(CollectionStoreAction::ToggleDirectory(request.get_id())); - } - RequestKind::Single(req) => { - store.dispatch(CollectionStoreAction::SetSelectedRequest(Some(req))); - return Ok(Some(SidebarEvent::RebuildView)); - } - } - } - KeyCode::Char('j') | KeyCode::Down => store.dispatch(CollectionStoreAction::HoverNext), - KeyCode::Char('k') | KeyCode::Up => store.dispatch(CollectionStoreAction::HoverPrev), - KeyCode::Char('n') => { - self.request_form = - RequestFormVariant::Create(RequestForm::::new( - self.colors, - self.collection_store.clone(), - )); - return Ok(Some(SidebarEvent::CreateRequest)); - } - KeyCode::Char('e') => { - let hovered_request = store.find_hovered_request(); - drop(store); - match hovered_request { - RequestKind::Single(req) => { - self.request_form = - RequestFormVariant::Edit(RequestForm::::new( - self.colors, - self.collection_store.clone(), - req.clone(), - )); - return Ok(Some(SidebarEvent::EditRequest)); - } - RequestKind::Nested(dir) => { - self.directory_form = - DirectoryFormVariant::Edit(DirectoryForm::::new( - self.colors, - self.collection_store.clone(), - Some((dir.id.clone(), dir.name.clone())), - )); - return Ok(Some(SidebarEvent::EditDirectory)); - } - } - } - KeyCode::Tab => return Ok(Some(SidebarEvent::SelectNext)), - KeyCode::BackTab => return Ok(Some(SidebarEvent::SelectPrev)), - KeyCode::Char('D') => { - if let Some(item_id) = store.get_hovered_request() { - return Ok(Some(SidebarEvent::DeleteItem(item_id))); - } - } - KeyCode::Char('d') => return Ok(Some(SidebarEvent::CreateDirectory)), - KeyCode::Esc => return Ok(Some(SidebarEvent::RemoveSelection)), - _ => {} - } - - drop(store); - self.rebuild_tree_view(); - - Ok(None) - } -} - -pub fn build_lines( - requests: Option>>>, - level: usize, - selected_request: Option>>, - hovered_request: Option, - dirs_expanded: Rc>>, - colors: &hac_colors::Colors, -) -> Vec> { - requests - .unwrap_or(Arc::new(RwLock::new(vec![]))) - .read() - .unwrap() - .iter() - .flat_map(|item| match item { - RequestKind::Nested(dir) => { - let is_hovered = hovered_request - .as_ref() - .is_some_and(|id| id.eq(&item.get_id())); - let mut dirs = dirs_expanded.borrow_mut(); - let is_expanded = dirs.entry(dir.id.to_string()).or_insert(false); - - let dir_style = match is_hovered { - true => Style::default() - .fg(colors.normal.white) - .bg(colors.primary.hover) - .bold(), - false => Style::default().fg(colors.normal.white).bold(), - }; - - let gap = " ".repeat(level * 2); - let chevron = if *is_expanded { "v" } else { ">" }; - let line = vec![Paragraph::new(format!( - "{}{} {}/", - gap, - chevron, - dir.name.to_lowercase().replace(' ', "-") - )) - .set_style(dir_style)]; - - let nested_lines = if *is_expanded { - build_lines( - Some(dir.requests.clone()), - level + 1, - selected_request.clone(), - hovered_request.clone(), - dirs_expanded.clone(), - colors, - ) - } else { - vec![] - }; - line.into_iter().chain(nested_lines).collect::>() - } - RequestKind::Single(req) => { - let gap = " ".repeat(level * 2); - let is_selected = selected_request.as_ref().is_some_and(|selected| { - selected.read().unwrap().id.eq(&req.read().unwrap().id) - }); - let is_hovered = hovered_request - .as_ref() - .is_some_and(|id| id.eq(&item.get_id())); - - let req_style = match (is_selected, is_hovered) { - (true, true) => Style::default() - .fg(colors.normal.yellow) - .bg(colors.normal.blue), - (true, _) => Style::default() - .fg(colors.normal.white) - .bg(colors.normal.blue), - (_, true) => Style::default() - .fg(colors.normal.white) - .bg(colors.primary.hover), - (false, false) => Style::default().fg(colors.normal.white), - }; - - let line: Line<'_> = vec![ - Span::from(gap.clone()), - colored_method(req.read().unwrap().method.clone(), colors), - Span::from(format!(" {}", req.read().unwrap().name.clone())), - ] - .into(); - - vec![Paragraph::new(line).set_style(req_style)] - } - }) - .collect() -} - -fn colored_method(method: RequestMethod, colors: &hac_colors::Colors) -> Span<'static> { - match method { - RequestMethod::Get => "GET ".fg(colors.normal.green).bold(), - RequestMethod::Post => "POST ".fg(colors.normal.magenta).bold(), - RequestMethod::Put => "PUT ".fg(colors.normal.yellow).bold(), - RequestMethod::Patch => "PATCH ".fg(colors.normal.orange).bold(), - RequestMethod::Delete => "DELETE".fg(colors.normal.red).bold(), - } -} diff --git a/hac-client/src/pages/collection_viewer/sidebar/create_directory_form.rs b/hac-client/src/pages/collection_viewer/sidebar/create_directory_form.rs deleted file mode 100644 index 1d972ff..0000000 --- a/hac-client/src/pages/collection_viewer/sidebar/create_directory_form.rs +++ /dev/null @@ -1,83 +0,0 @@ -use hac_core::collection::types::*; - -use super::directory_form::{DirectoryForm, DirectoryFormCreate, DirectoryFormEvent}; -use crate::ascii::LOGO_ASCII; -use crate::pages::collection_viewer::collection_store::CollectionStore; -use crate::pages::collection_viewer::sidebar::DirectoryFormTrait; -use crate::pages::Eventful; - -use std::cell::RefCell; -use std::rc::Rc; -use std::sync::{Arc, RwLock}; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use rand::Rng; - -impl<'df> DirectoryForm<'df, DirectoryFormCreate> { - pub fn new( - colors: &'df hac_colors::Colors, - collection_store: Rc>, - ) -> DirectoryForm<'df, DirectoryFormCreate> { - let logo_idx = rand::thread_rng().gen_range(0..LOGO_ASCII.len()); - - DirectoryForm { - colors, - collection_store, - dir_name: String::default(), - logo_idx, - marker: std::marker::PhantomData, - directory: None, - } - } -} - -impl DirectoryFormTrait for DirectoryForm<'_, DirectoryFormCreate> {} - -impl Eventful for DirectoryForm<'_, DirectoryFormCreate> { - type Result = DirectoryFormEvent; - - fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { - if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { - self.reset(); - return Ok(Some(DirectoryFormEvent::Cancel)); - } - - match key_event.code { - KeyCode::Esc => { - self.reset(); - return Ok(Some(DirectoryFormEvent::Cancel)); - } - KeyCode::Enter => { - let store = self.collection_store.borrow_mut(); - let collection = store - .get_collection() - .expect("tried to create a request without a collection"); - - let mut collection = collection.borrow_mut(); - let requests = collection - .requests - .get_or_insert(Arc::new(RwLock::new(vec![]))); - let mut requests = requests.write().unwrap(); - - if self.dir_name.is_empty() { - self.dir_name = "unnamed directory".into(); - } - - requests.push(RequestKind::Nested(Directory { - id: uuid::Uuid::new_v4().to_string(), - name: self.dir_name.clone(), - requests: Arc::new(RwLock::new(vec![])), - })); - - drop(store); - self.reset(); - return Ok(Some(DirectoryFormEvent::Confirm)); - } - KeyCode::Char(c) => self.dir_name.push(c), - KeyCode::Backspace => _ = self.dir_name.pop(), - _ => {} - } - - Ok(None) - } -} diff --git a/hac-client/src/pages/collection_viewer/sidebar/create_request_form.rs b/hac-client/src/pages/collection_viewer/sidebar/create_request_form.rs index cc5b46b..bbafded 100644 --- a/hac-client/src/pages/collection_viewer/sidebar/create_request_form.rs +++ b/hac-client/src/pages/collection_viewer/sidebar/create_request_form.rs @@ -1,231 +1,157 @@ -use hac_core::collection::types::*; - -use super::request_form::FormField; -use super::request_form::RequestForm; -use super::request_form::RequestFormCreate; -use super::request_form::RequestFormEvent; -use super::select_request_parent::{SelectRequestParent, SelectRequestParentEvent}; -use super::RequestFormTrait; -use crate::ascii::LOGO_ASCII; -use crate::pages::collection_viewer::collection_store::CollectionStore; -use crate::pages::collection_viewer::collection_viewer::CollectionViewerOverlay; -use crate::pages::{Eventful, Renderable}; - -use std::cell::RefCell; -use std::ops::Sub; -use std::rc::Rc; -use std::sync::{Arc, RwLock}; +use std::sync::mpsc::{channel, Sender}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use rand::Rng; +use hac_core::command::Command; +use hac_store::collection::ReqMethod; +use hac_store::slab::Key; +use ratatui::layout::Rect; use ratatui::Frame; -impl<'rf> RequestFormTrait for RequestForm<'rf, RequestFormCreate> { - fn draw_overlay( - &mut self, - frame: &mut Frame, - overlay: CollectionViewerOverlay, - ) -> anyhow::Result<()> { - if let CollectionViewerOverlay::SelectParentDir = overlay { - self.parent_selector.draw(frame, frame.size())?; - } - - Ok(()) - } +use super::request_form_layout::{ + build_req_form_layout, draw_main_form, draw_parent_selector, FieldFocus, FormStep, ReqFormLayout, +}; +use crate::app::Routes; +use crate::components::blending_list::BlendingList; +use crate::pages::overlay::make_overlay; +use crate::renderable::{Eventful, Renderable}; +use crate::router::RouterMessage; +use crate::{router_drop_dialog, HacColors}; + +#[derive(Debug)] +pub struct CreateRequestForm { + colors: HacColors, + layout: ReqFormLayout, + name: String, + method: ReqMethod, + focus: FieldFocus, + parent: Option, + form_step: FormStep, + parent_listing: BlendingList, + messager: Sender, } -impl<'rf> RequestForm<'rf, RequestFormCreate> { - pub fn new( - colors: &'rf hac_colors::Colors, - collection_store: Rc>, - ) -> Self { - let logo_idx = rand::thread_rng().gen_range(0..LOGO_ASCII.len()); - - RequestForm { +impl CreateRequestForm { + pub fn new(colors: HacColors, area: Rect) -> Self { + Self { + layout: build_req_form_layout(area), + name: Default::default(), + method: Default::default(), + focus: Default::default(), + parent: None, + form_step: FormStep::MainForm, + parent_listing: BlendingList::new(0, hac_store::collection::len_folders(), 13, 0, colors.clone()), colors, - parent_selector: SelectRequestParent::new(colors, collection_store.clone()), - collection_store, - logo_idx, - request_name: String::default(), - request_method: RequestMethod::Get, - parent_dir: None, - focused_field: FormField::Name, - marker: std::marker::PhantomData, - request: None, - no_available_parent_timer: None, + messager: channel().0, } } -} -impl Eventful for RequestForm<'_, RequestFormCreate> { - type Result = RequestFormEvent; + fn main_form_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { + match key_event.code { + KeyCode::Char('p') if matches!(key_event.modifiers, KeyModifiers::CONTROL) => { + self.form_step = FormStep::ParentSelector; + } - #[tracing::instrument(skip_all, err)] - fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { - let overlay = self.collection_store.borrow_mut().peek_overlay(); - - if overlay.eq(&CollectionViewerOverlay::SelectParentDir) { - match self.parent_selector.handle_key_event(key_event)? { - Some(SelectRequestParentEvent::Confirm(dir_id)) => { - let mut store = self.collection_store.borrow_mut(); - let collection = store - .get_collection() - .expect("tried attach a parent to a request without having a collection"); - let collection = collection.borrow(); - let requests = collection - .requests - .as_ref() - .expect("tried to attach a parent to a request with empty collection"); - let dir_name = requests - .read() - .unwrap() - .iter() - .find(|req| req.get_id().eq(&dir_id)) - .as_ref() - // its safe to unwrap here as to have an id we for sure have the directory - .unwrap() - .get_name(); - self.parent_dir = Some((dir_id, dir_name)); - store.pop_overlay(); - } - Some(SelectRequestParentEvent::Cancel) => { - self.collection_store.borrow_mut().pop_overlay(); - } - None => {} + KeyCode::Char(ch) if matches!(self.focus, FieldFocus::Name) => self.name.push(ch), + KeyCode::Backspace if matches!(self.focus, FieldFocus::Name) => _ = self.name.pop(), + + KeyCode::Left if matches!(self.focus, FieldFocus::Methods) => self.method.set_prev(), + KeyCode::Right if matches!(self.focus, FieldFocus::Methods) => self.method.set_next(), + KeyCode::Up if matches!(self.focus, FieldFocus::Methods) => self.method.set_first(), + KeyCode::Down if matches!(self.focus, FieldFocus::Methods) => self.method.set_last(), + KeyCode::Char('h') if matches!(self.focus, FieldFocus::Methods) => self.method.set_prev(), + KeyCode::Char('j') if matches!(self.focus, FieldFocus::Methods) => self.method.set_last(), + KeyCode::Char('k') if matches!(self.focus, FieldFocus::Methods) => self.method.set_first(), + KeyCode::Char('l') if matches!(self.focus, FieldFocus::Methods) => self.method.set_next(), + KeyCode::Char(ch @ '1'..='5') if matches!(self.focus, FieldFocus::Methods) => { + self.method = ReqMethod::from(ch) } - return Ok(None); - } - if let KeyCode::Tab = key_event.code { - self.focused_field = self.focused_field.next(); - return Ok(None); - } + KeyCode::Backspace if matches!(self.focus, FieldFocus::Parent) => self.parent = None, - if let KeyCode::BackTab = key_event.code { - self.focused_field = self.focused_field.prev(); - return Ok(None); - } + KeyCode::Tab => self.focus.next(), + KeyCode::BackTab => self.focus.prev(), + KeyCode::Esc => { + router_drop_dialog!(&self.messager, Routes::CreateRequest.into()); + } + KeyCode::Enter => { + let request = hac_store::collection::Request::new(self.name.clone(), self.method, self.parent); + hac_store::collection::push_request(request, self.parent); + hac_store::collection::rebuild_tree_layout(); + router_drop_dialog!(&self.messager, Routes::CreateRequest.into()); + } + _ => {} + }; - if let (KeyCode::Char('p'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { - self.parent_dir = None; - return Ok(None); - } + Ok(None) + } - if let KeyCode::Enter = key_event.code { - let store = self.collection_store.borrow_mut(); - let collection = store - .get_collection() - .expect("tried to create a request without a collection"); + fn parent_selector_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { + match key_event.code { + KeyCode::Char('j') | KeyCode::Down => self.parent_listing.select_down(), + KeyCode::Char('k') | KeyCode::Up => self.parent_listing.select_up(), + KeyCode::Esc => self.form_step = FormStep::MainForm, + KeyCode::Enter => { + if hac_store::collection::has_folders() { + self.parent = Some(self.parent_listing.selected); + self.form_step = FormStep::MainForm; + self.parent_listing.reset() + } + } + _ => {} + }; - let mut collection = collection.borrow_mut(); - let requests = collection - .requests - .get_or_insert(Arc::new(RwLock::new(vec![]))); - let mut requests = requests.write().unwrap(); + Ok(None) + } +} - if self.request_name.is_empty() { - self.request_name = String::from("unnamed request"); +impl Renderable for CreateRequestForm { + type Input = (); + type Output = (); + + fn draw(&mut self, frame: &mut Frame, _: Rect) -> anyhow::Result<()> { + make_overlay(self.colors.clone(), self.colors.normal.black, 0.15, frame); + + match self.form_step { + FormStep::MainForm => draw_main_form( + &self.name, + self.method, + self.parent, + self.focus, + &self.layout, + &self.colors, + frame, + ), + FormStep::ParentSelector => { + draw_parent_selector(&mut self.parent_listing, &self.layout, &self.colors, frame) } + } - let request = RequestKind::Single(Arc::new(RwLock::new(Request { - id: uuid::Uuid::new_v4().to_string(), - auth_method: None, - body: None, - body_type: None, - parent: self.parent_dir.as_ref().map(|(id, _)| id.clone()), - headers: None, - method: self.request_method.clone(), - name: self.request_name.clone(), - uri: String::default(), - }))); - - if let Some((dir_id, _)) = self.parent_dir.as_ref() { - if let RequestKind::Nested(dir) = requests - .iter_mut() - .find(|req| req.get_id().eq(dir_id)) - .unwrap() - { - dir.requests.write().unwrap().push(request); - } - } else { - requests.push(request); - } + Ok(()) + } - drop(store); - self.reset(); - return Ok(Some(RequestFormEvent::Confirm)); - } + fn data(&self, _: u8) -> Self::Output {} - if let KeyCode::Esc = key_event.code { - self.reset(); - return Ok(Some(RequestFormEvent::Cancel)); - } + fn attach_navigator(&mut self, messager: Sender) { + self.messager = messager; + } + fn resize(&mut self, new_size: Rect) { + self.layout = build_req_form_layout(new_size); + } +} + +impl Eventful for CreateRequestForm { + type Result = Command; + + fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { - self.reset(); - return Ok(Some(RequestFormEvent::Cancel)); + return Ok(Some(Command::Quit)); } - match self.focused_field { - FormField::Name => match key_event.code { - KeyCode::Char(c) => { - self.request_name.push(c); - } - KeyCode::Backspace => { - self.request_name.pop(); - } - _ => {} - }, - FormField::Method => match key_event.code { - KeyCode::Char(c @ '1'..='5') => { - self.request_method = (c.to_digit(10).unwrap() as usize).sub(1).try_into()?; - } - KeyCode::Left => self.request_method = self.request_method.prev(), - KeyCode::Down => self.request_method = 4.try_into()?, - KeyCode::Up => self.request_method = 0.try_into()?, - KeyCode::Right => self.request_method = self.request_method.next(), - KeyCode::Char('h') => self.request_method = self.request_method.prev(), - KeyCode::Char('j') => self.request_method = 4.try_into()?, - KeyCode::Char('k') => self.request_method = 0.try_into()?, - KeyCode::Char('l') => self.request_method = self.request_method.next(), - _ => {} - }, - FormField::Parent => { - if let KeyCode::Char(' ') = key_event.code { - let mut store = self.collection_store.borrow_mut(); - let collection = store - .get_collection() - .expect("tried to select a parent without a collection"); - let collection = collection.borrow(); - - let Some(requests) = collection.requests.as_ref() else { - drop(store); - self.set_no_parent_timer(); - return Ok(None); - }; - - let requests = requests.read().unwrap(); - if requests.is_empty() { - drop(store); - self.set_no_parent_timer(); - return Ok(None); - } - - if requests - .iter() - .filter(|req| req.is_dir()) - .collect::>() - .is_empty() - { - drop(store); - self.set_no_parent_timer(); - return Ok(None); - } - - store.push_overlay(CollectionViewerOverlay::SelectParentDir); - } - } - } + match self.form_step { + FormStep::MainForm => self.main_form_key_event(key_event)?, + FormStep::ParentSelector => self.parent_selector_key_event(key_event)?, + }; Ok(None) } diff --git a/hac-client/src/pages/collection_viewer/sidebar/delete_item_form.rs b/hac-client/src/pages/collection_viewer/sidebar/delete_item_form.rs new file mode 100644 index 0000000..b3aa5bb --- /dev/null +++ b/hac-client/src/pages/collection_viewer/sidebar/delete_item_form.rs @@ -0,0 +1,190 @@ +use std::sync::mpsc::{channel, Sender}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use hac_core::command::Command; +use hac_store::collection::WhichSlab; +use ratatui::layout::{Constraint, Flex, Layout, Margin, Rect}; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::Frame; + +use crate::app::Routes; +use crate::ascii::LOGO_ASCII; +use crate::pages::overlay::make_overlay; +use crate::renderable::{Eventful, Renderable}; +use crate::router::RouterMessage; +use crate::{router_drop_dialog, HacColors, MIN_HEIGHT, MIN_WIDTH}; + +#[derive(Debug)] +struct DeleteItemLayout { + logo: Rect, + message: Rect, + left_button: Rect, + right_button: Rect, +} + +#[derive(Debug)] +pub struct DeleteItemForm { + colors: HacColors, + layout: DeleteItemLayout, + messager: Sender, +} + +impl DeleteItemForm { + pub fn new(colors: HacColors, size: Rect) -> Self { + DeleteItemForm { + colors, + layout: build_layout(size), + messager: channel().0, + } + } +} + +impl Renderable for DeleteItemForm { + type Input = (); + type Output = (); + + fn draw(&mut self, frame: &mut Frame, _: Rect) -> anyhow::Result<()> { + make_overlay(self.colors.clone(), self.colors.normal.black, 0.15, frame); + + let (slab, key) = + hac_store::collection::get_hovered_request(|entry| entry).expect("deleting a non-existing request??"); + + let message = match slab { + WhichSlab::Requests => hac_store::collection::get_request(key, |req, _| { + vec![ + "are you sure you want to delete the request " + .fg(self.colors.bright.red) + .not_bold(), + req.name.clone().fg(self.colors.bright.red).bold().underlined(), + "?".fg(self.colors.bright.red).not_bold(), + ] + }), + WhichSlab::RootRequests => hac_store::collection::get_root_request(key, |req, _| { + vec![ + "are you sure you want to delete the request " + .fg(self.colors.bright.red) + .not_bold(), + req.name.clone().fg(self.colors.bright.red).bold().underlined(), + "?".fg(self.colors.bright.red).not_bold(), + ] + }), + WhichSlab::Folders => hac_store::collection::get_folder(key, |folder, _| { + vec![ + "are you sure you want to delete the folder " + .fg(self.colors.bright.red) + .not_bold(), + folder.name.clone().fg(self.colors.bright.red).bold().underlined(), + "? This will also delete every request inside of this folder" + .fg(self.colors.bright.red) + .not_bold(), + ] + }), + }; + + let message = Paragraph::new(Line::from(message).centered()).wrap(Wrap { trim: true }); + + let left_button = Paragraph::new(Line::from("[ENTER] CONFIRM").centered().fg(self.colors.normal.black)) + .block(Block::default().borders(Borders::ALL).fg(self.colors.bright.red)) + .bg(self.colors.bright.red) + .bold(); + + let right_button = Paragraph::new(Line::from("[ESC] CANCEL").centered()) + .block(Block::default().borders(Borders::ALL).fg(self.colors.normal.blue)) + .fg(self.colors.bright.red) + .bg(self.colors.normal.blue) + .bold(); + + frame.render_widget(message, self.layout.message); + frame.render_widget(left_button, self.layout.left_button); + frame.render_widget(right_button, self.layout.right_button); + + let logo = LOGO_ASCII + .iter() + .map(|line| Line::from(line.fg(self.colors.normal.red)).centered()) + .collect::>(); + frame.render_widget(Paragraph::new(logo), self.layout.logo); + + Ok(()) + } + + fn attach_navigator(&mut self, messager: Sender) { + self.messager = messager; + } + + fn data(&self, _: u8) -> Self::Output {} +} + +impl Eventful for DeleteItemForm { + type Result = Command; + + fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { + if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { + return Ok(Some(Command::Quit)); + } + + match key_event.code { + KeyCode::Esc => { + router_drop_dialog!(&self.messager, Routes::DeleteItem.into()); + } + KeyCode::Char('o') | KeyCode::Char('y') | KeyCode::Enter => { + let hovered_entry = hac_store::collection::get_hovered_request(|entry| entry) + .expect("should have hovered request to delete"); + let selected_entry = hac_store::collection::get_selected_entry(); + + hac_store::collection::hover_prev(); + + match hovered_entry.0 { + WhichSlab::Requests => _ = hac_store::collection::remove_request(hovered_entry.1), + WhichSlab::RootRequests => _ = hac_store::collection::remove_root_request(hovered_entry.1), + WhichSlab::Folders => _ = hac_store::collection::remove_folder(hovered_entry.1), + }; + + if let Some((slab, key)) = selected_entry { + if slab == hovered_entry.0 && key == hovered_entry.1 { + hac_store::collection::set_selected_request(None); + } + } + + hac_store::collection::rebuild_tree_layout(); + router_drop_dialog!(&self.messager, Routes::DeleteItem.into()); + } + _ => {} + } + + Ok(None) + } +} + +fn build_layout(area: Rect) -> DeleteItemLayout { + let [_, form, _] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(MIN_WIDTH), Constraint::Fill(1)]) + .flex(Flex::Center) + .areas(area); + + let [_, form, _] = Layout::vertical([Constraint::Fill(1), Constraint::Length(MIN_HEIGHT), Constraint::Fill(1)]) + .flex(Flex::End) + .areas(form); + + let form = form.inner(&Margin::new(2, 0)); + + let [logo, _, message, _, buttons] = Layout::vertical([ + Constraint::Length(LOGO_ASCII.len() as u16), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(3), + ]) + .flex(Flex::Center) + .areas(form); + + let [left_button, _, right_button] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(1), Constraint::Fill(1)]).areas(buttons); + + DeleteItemLayout { + logo, + message, + left_button, + right_button, + } +} diff --git a/hac-client/src/pages/collection_viewer/sidebar/delete_item_prompt.rs b/hac-client/src/pages/collection_viewer/sidebar/delete_item_prompt.rs deleted file mode 100644 index 04e2400..0000000 --- a/hac-client/src/pages/collection_viewer/sidebar/delete_item_prompt.rs +++ /dev/null @@ -1,136 +0,0 @@ -use crate::ascii::LOGO_ASCII; -use crate::pages::collection_viewer::collection_store::CollectionStore; -use crate::pages::overlay::make_overlay; -use crate::pages::{Eventful, Renderable}; - -use std::cell::RefCell; -use std::ops::{Add, Div, Mul, Sub}; -use std::rc::Rc; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use rand::Rng; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Paragraph; -use ratatui::Frame; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DeleteItemPromptEvent { - Confirm, - Cancel, -} - -#[derive(Debug)] -pub struct DeleteItemPrompt<'dip> { - colors: &'dip hac_colors::Colors, - collection_store: Rc>, - logo_idx: usize, -} - -impl<'dip> DeleteItemPrompt<'dip> { - pub fn new( - colors: &'dip hac_colors::Colors, - collection_store: Rc>, - ) -> Self { - let logo_idx = rand::thread_rng().gen_range(0..LOGO_ASCII.len()); - DeleteItemPrompt { - colors, - logo_idx, - collection_store, - } - } -} - -impl Renderable for DeleteItemPrompt<'_> { - fn draw(&mut self, frame: &mut Frame, _: Rect) -> anyhow::Result<()> { - make_overlay(self.colors, self.colors.normal.black, 0.1, frame); - - let store = self.collection_store.borrow(); - let Some(hovered_id) = store.get_hovered_request().as_ref().cloned() else { - unreachable!(); - }; - let Some(collection) = store.get_collection() else { - unreachable!(); - }; - let collection = collection.borrow(); - let Some(ref requests) = collection.requests else { - unreachable!(); - }; - let is_dir = requests - .read() - .unwrap() - .iter() - .find(|req| req.get_id().eq(&hovered_id)) - .is_some_and(|req| req.is_dir()); - - let mut lines = if is_dir { - vec![ - Line::from( - "Are you sure you want to delete the directory?".fg(self.colors.normal.red), - ) - .centered(), - Line::from("This will delete all the requests inside".fg(self.colors.normal.red)) - .centered(), - Line::from(""), - ] - } else { - vec![ - Line::from( - "Are you sure you want to delete the request?".fg(self.colors.normal.red), - ) - .centered(), - Line::from(""), - ] - }; - - lines.push( - Line::from("[Confirm: Enter] [Cancel: Esc]".fg(self.colors.bright.black)).centered(), - ); - - let logo = LOGO_ASCII[self.logo_idx]; - let logo_size = logo.len() as u16; - let size = frame.size(); - - let size = Rect::new( - size.width.div(2).sub(25), - size.height - .div(2) - .saturating_sub(logo_size.div(2)) - .saturating_sub(3), - 50, - logo_size.add(7), - ); - - let logo_size = Rect::new(size.x, size.y, size.width, logo_size); - let logo = logo - .iter() - .map(|line| Line::from(line.fg(self.colors.normal.red)).centered()) - .collect::>(); - frame.render_widget(Paragraph::new(logo), logo_size); - - for (idx, line) in lines.into_iter().enumerate() { - let y_offset = logo_size.height.add(2).add(idx.mul(1) as u16); - let line_size = Rect::new(size.x, size.y.add(y_offset), size.width, 1); - frame.render_widget(Paragraph::new(line), line_size); - } - - Ok(()) - } -} - -impl Eventful for DeleteItemPrompt<'_> { - type Result = DeleteItemPromptEvent; - - fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { - if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { - return Ok(Some(DeleteItemPromptEvent::Cancel)); - } - - match key_event.code { - KeyCode::Enter => Ok(Some(DeleteItemPromptEvent::Confirm)), - KeyCode::Esc => Ok(Some(DeleteItemPromptEvent::Cancel)), - _ => Ok(None), - } - } -} diff --git a/hac-client/src/pages/collection_viewer/sidebar/directory_form.rs b/hac-client/src/pages/collection_viewer/sidebar/directory_form.rs deleted file mode 100644 index 6766369..0000000 --- a/hac-client/src/pages/collection_viewer/sidebar/directory_form.rs +++ /dev/null @@ -1,110 +0,0 @@ -use crate::ascii::LOGO_ASCII; -use crate::pages::collection_viewer::collection_store::CollectionStore; -use crate::pages::input::Input; -use crate::pages::overlay::make_overlay; -use crate::pages::Renderable; - -use std::cell::RefCell; -use std::ops::{Add, Div, Sub}; -use std::rc::Rc; - -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Paragraph; - -/// set of events `DirectoryForm` can send the parent to -/// handle -#[derive(Debug, PartialEq, Eq, Clone)] -pub enum DirectoryFormEvent { - /// when user confirms the directory creation we should notify - /// the parent to properly handle the event - Confirm, - /// when the user cancels the creation, we should also notify - /// the parent to properly clean up things - Cancel, -} - -#[derive(Debug)] -pub struct DirectoryFormCreate; - -#[derive(Debug)] -pub struct DirectoryFormEdit; - -#[derive(Debug)] -pub struct DirectoryForm<'df, State = DirectoryFormCreate> { - pub colors: &'df hac_colors::Colors, - pub dir_name: String, - pub collection_store: Rc>, - pub logo_idx: usize, - /// the id of the directory being edited, this is only used when editing a directory - /// this is (dir_id, dir_name) - pub directory: Option<(String, String)>, - - pub marker: std::marker::PhantomData, -} - -impl<'cdf, State> DirectoryForm<'cdf, State> { - pub fn reset(&mut self) { - self.dir_name.clear(); - } -} - -impl Renderable for DirectoryForm<'_, State> { - fn draw( - &mut self, - frame: &mut ratatui::prelude::Frame, - _: ratatui::prelude::Rect, - ) -> anyhow::Result<()> { - make_overlay(self.colors, self.colors.normal.black, 0.1, frame); - - let logo = LOGO_ASCII[self.logo_idx]; - let logo_size = logo.len() as u16; - - let size = frame.size(); - let size = Rect::new( - size.width.div(2).sub(25), - size.height - .div(2) - .saturating_sub(logo_size.div(2)) - .saturating_sub(2), - 50, - logo_size.add(4), - ); - - let logo = logo - .iter() - .map(|line| Line::from(line.to_string().fg(self.colors.normal.red)).centered()) - .collect::>(); - - let mut input = Input::new(self.colors, "Name".into()); - input.focus(); - - let hint = Line::from("[Confirm: Enter] [Cancel: Esc]") - .fg(self.colors.bright.black) - .centered(); - - let logo_size = Rect::new(size.x, size.y, size.width, logo_size); - let input_size = Rect::new( - size.x, - logo_size.y.add(logo_size.height).add(1), - size.width, - 3, - ); - let hint_size = Rect::new(size.x, input_size.y.add(4), size.width, 1); - - frame.render_widget(Paragraph::new(logo), logo_size); - frame.render_stateful_widget(input, input_size, &mut self.dir_name); - frame.render_widget(hint, hint_size); - - frame.set_cursor( - input_size - .x - .add(self.dir_name.chars().count() as u16) - .add(1), - input_size.y.add(1), - ); - - Ok(()) - } -} diff --git a/hac-client/src/pages/collection_viewer/sidebar/edit_directory_form.rs b/hac-client/src/pages/collection_viewer/sidebar/edit_directory_form.rs deleted file mode 100644 index 407825a..0000000 --- a/hac-client/src/pages/collection_viewer/sidebar/edit_directory_form.rs +++ /dev/null @@ -1,89 +0,0 @@ -use hac_core::collection::types::*; - -use super::directory_form::{DirectoryForm, DirectoryFormEdit, DirectoryFormEvent}; -use crate::ascii::LOGO_ASCII; -use crate::pages::collection_viewer::collection_store::CollectionStore; -use crate::pages::collection_viewer::sidebar::DirectoryFormTrait; -use crate::pages::Eventful; - -use std::cell::RefCell; -use std::rc::Rc; -use std::sync::{Arc, RwLock}; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use rand::Rng; - -impl<'df> DirectoryForm<'df, DirectoryFormEdit> { - pub fn new( - colors: &'df hac_colors::Colors, - collection_store: Rc>, - directory: Option<(String, String)>, - ) -> DirectoryForm<'df, DirectoryFormEdit> { - let logo_idx = rand::thread_rng().gen_range(0..LOGO_ASCII.len()); - let dir_name = directory - .as_ref() - .map(|dir| dir.1.clone()) - .unwrap_or_default(); - - DirectoryForm { - colors, - collection_store, - dir_name, - logo_idx, - marker: std::marker::PhantomData, - directory, - } - } -} - -impl DirectoryFormTrait for DirectoryForm<'_, DirectoryFormEdit> {} - -impl Eventful for DirectoryForm<'_, DirectoryFormEdit> { - type Result = DirectoryFormEvent; - - fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { - if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { - self.reset(); - return Ok(Some(DirectoryFormEvent::Cancel)); - } - - match key_event.code { - KeyCode::Esc => { - self.reset(); - return Ok(Some(DirectoryFormEvent::Cancel)); - } - KeyCode::Enter => { - let store = self.collection_store.borrow_mut(); - let collection = store - .get_collection() - .expect("tried to create a request without a collection"); - - let mut collection = collection.borrow_mut(); - let requests = collection - .requests - .get_or_insert(Arc::new(RwLock::new(vec![]))); - let mut requests = requests.write().unwrap(); - - if self.dir_name.is_empty() { - self.dir_name = "unnamed directory".into(); - } - - if let Some(RequestKind::Nested(dir)) = requests - .iter_mut() - .find(|req| req.get_id().eq(&self.directory.as_ref().unwrap().0)) - { - dir.name.clone_from(&self.dir_name); - } - - drop(store); - self.reset(); - return Ok(Some(DirectoryFormEvent::Confirm)); - } - KeyCode::Char(c) => self.dir_name.push(c), - KeyCode::Backspace => _ = self.dir_name.pop(), - _ => {} - } - - Ok(None) - } -} diff --git a/hac-client/src/pages/collection_viewer/sidebar/edit_request_form.rs b/hac-client/src/pages/collection_viewer/sidebar/edit_request_form.rs index c8b4f71..3171321 100644 --- a/hac-client/src/pages/collection_viewer/sidebar/edit_request_form.rs +++ b/hac-client/src/pages/collection_viewer/sidebar/edit_request_form.rs @@ -1,264 +1,219 @@ -use hac_core::collection::types::*; - -use super::request_form::FormField; -use super::request_form::RequestForm; -use super::request_form::RequestFormEdit; -use super::request_form::RequestFormEvent; -use super::select_request_parent::{SelectRequestParent, SelectRequestParentEvent}; -use super::RequestFormTrait; -use crate::ascii::LOGO_ASCII; -use crate::pages::collection_viewer::collection_store::CollectionStore; -use crate::pages::collection_viewer::collection_viewer::CollectionViewerOverlay; -use crate::pages::{Eventful, Renderable}; - -use std::cell::RefCell; -use std::ops::Sub; -use std::rc::Rc; -use std::sync::{Arc, RwLock}; +use std::sync::mpsc::{channel, Sender}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use rand::Rng; +use hac_core::command::Command; +use hac_store::collection::{EntryStatus, ReqMethod, WhichSlab}; +use hac_store::slab::Key; +use ratatui::layout::Rect; use ratatui::Frame; -impl<'rf> RequestFormTrait for RequestForm<'rf, RequestFormEdit> { - fn draw_overlay( - &mut self, - frame: &mut Frame, - overlay: CollectionViewerOverlay, - ) -> anyhow::Result<()> { - if let CollectionViewerOverlay::SelectParentDir = overlay { - self.parent_selector.draw(frame, frame.size())?; - } - - Ok(()) - } +use super::request_form_layout::{ + build_req_form_layout, draw_main_form, draw_parent_selector, FieldFocus, FormStep, ReqFormLayout, +}; +use crate::app::Routes; +use crate::components::blending_list::BlendingList; +use crate::pages::overlay::make_overlay; +use crate::renderable::{Eventful, Renderable}; +use crate::router::RouterMessage; +use crate::{router_drop_dialog, HacColors}; + +#[derive(Debug)] +pub struct EditRequestForm { + colors: HacColors, + layout: ReqFormLayout, + name: String, + method: ReqMethod, + focus: FieldFocus, + parent: Option, + prev_parent: Option, + key: Key, + form_step: FormStep, + parent_listing: BlendingList, + messager: Sender, } -impl<'rf> RequestForm<'rf, RequestFormEdit> { - pub fn new( - colors: &'rf hac_colors::Colors, - collection_store: Rc>, - request: Arc>, - ) -> Self { - let logo_idx = rand::thread_rng().gen_range(0..LOGO_ASCII.len()); - let request_method = request.read().unwrap().method.clone(); - let request_name = request.read().unwrap().name.clone(); - - let parent_dir = if request.read().unwrap().parent.is_some() { - let store = collection_store.borrow(); - let Some(collection) = store.get_collection() else { - unreachable!(); - }; - let collection = collection.borrow(); - let Some(requests) = collection.requests.as_ref() else { - unreachable!(); - }; - - let dir_id = request.read().unwrap().parent.clone().unwrap(); - let dir_name = requests - .read() - .unwrap() - .iter() - .find(|req| req.get_id().eq(&dir_id)) - .unwrap() - .get_name(); - - Some((dir_id, dir_name)) - } else { - None - }; - - RequestForm { +impl EditRequestForm { + pub fn new(name: String, method: ReqMethod, key: Key, parent: Option, colors: HacColors, area: Rect) -> Self { + Self { + layout: build_req_form_layout(area), + name, + method, + parent, + prev_parent: parent, + key, + focus: Default::default(), + form_step: FormStep::MainForm, + parent_listing: BlendingList::new(0, hac_store::collection::len_folders(), 13, 0, colors.clone()), colors, - parent_selector: SelectRequestParent::new(colors, collection_store.clone()), - collection_store, - logo_idx, - request_name, - request_method, - parent_dir, - focused_field: FormField::Name, - marker: std::marker::PhantomData, - request: Some(request), - no_available_parent_timer: None, + messager: channel().0, } } -} -impl Eventful for RequestForm<'_, RequestFormEdit> { - type Result = RequestFormEvent; - - #[tracing::instrument(skip_all, err)] - fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { - let overlay = self.collection_store.borrow().peek_overlay(); - if let CollectionViewerOverlay::SelectParentDir = overlay { - match self.parent_selector.handle_key_event(key_event)? { - Some(SelectRequestParentEvent::Confirm(dir_id)) => { - let mut store = self.collection_store.borrow_mut(); - let collection = store - .get_collection() - .expect("tried attach a parent to a request without having a collection"); - let collection = collection.borrow(); - let requests = collection - .requests - .as_ref() - .expect("tried to attach a parent to a request with empty collection"); - let dir_name = requests - .read() - .unwrap() - .iter() - .find(|req| req.get_id().eq(&dir_id)) - .as_ref() - // its safe to unwrap here as to have an id we for sure have the directory - .unwrap() - .get_name(); - tracing::debug!("before: {:?}", self.parent_dir); - self.parent_dir = Some((dir_id, dir_name)); - store.pop_overlay(); - } - Some(SelectRequestParentEvent::Cancel) => { - self.collection_store.borrow_mut().pop_overlay(); - } - None => {} + fn main_form_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { + match key_event.code { + KeyCode::Char('p') if matches!(key_event.modifiers, KeyModifiers::CONTROL) => { + self.form_step = FormStep::ParentSelector; } - return Ok(None); - } - - if let KeyCode::Tab = key_event.code { - self.focused_field = self.focused_field.next(); - return Ok(None); - } - - if let KeyCode::BackTab = key_event.code { - self.focused_field = self.focused_field.prev(); - return Ok(None); - } - - if let KeyCode::Enter = key_event.code { - let request = self.request.as_mut().unwrap(); - let mut request = request.write().unwrap(); - - request.name.clone_from(&self.request_name); - request.method.clone_from(&self.request_method); - request.parent = self.parent_dir.as_ref().map(|(id, _)| id.clone()); - let request_id = request.id.clone(); - - drop(request); + KeyCode::Char(ch) if matches!(self.focus, FieldFocus::Name) => self.name.push(ch), + KeyCode::Backspace if matches!(self.focus, FieldFocus::Name) => _ = self.name.pop(), + + KeyCode::Left if matches!(self.focus, FieldFocus::Methods) => self.method.set_prev(), + KeyCode::Right if matches!(self.focus, FieldFocus::Methods) => self.method.set_next(), + KeyCode::Up if matches!(self.focus, FieldFocus::Methods) => self.method.set_first(), + KeyCode::Down if matches!(self.focus, FieldFocus::Methods) => self.method.set_last(), + KeyCode::Char('h') if matches!(self.focus, FieldFocus::Methods) => self.method.set_prev(), + KeyCode::Char('j') if matches!(self.focus, FieldFocus::Methods) => self.method.set_last(), + KeyCode::Char('k') if matches!(self.focus, FieldFocus::Methods) => self.method.set_first(), + KeyCode::Char('l') if matches!(self.focus, FieldFocus::Methods) => self.method.set_next(), + KeyCode::Char(ch @ '1'..='5') if matches!(self.focus, FieldFocus::Methods) => { + self.method = ReqMethod::from(ch) + } - let store = self.collection_store.borrow_mut(); - let collection = store - .get_collection() - .expect("tried to create a request without a collection"); - let mut collection = collection.borrow_mut(); - let requests = collection - .requests - .get_or_insert(Arc::new(RwLock::new(vec![]))); - let mut requests = requests.write().unwrap(); + KeyCode::Backspace if matches!(self.focus, FieldFocus::Parent) => self.parent = None, - requests.iter_mut().for_each(|req| { - if let RequestKind::Nested(dir) = req { - dir.requests - .write() - .unwrap() - .retain(|req| req.get_id().ne(&request_id)); + KeyCode::Tab => self.focus.next(), + KeyCode::BackTab => self.focus.prev(), + KeyCode::Esc => { + router_drop_dialog!(&self.messager, Routes::EditRequest.into()); + } + KeyCode::Enter => { + // editing a request gives us a few possible scenarios: + // 1. parent of the request didn't change, either if it had no parent and still has + // no parent, or if it had one and it wasn't changed. + // - in this case, we just need to update the request on the current slab it + // lives in + // 2. parent of the request changed from not having a parent to having a parent. + // - in this case we need to remove the request from root_requests and move it + // to requests, also updating the new parent to hold the key to its new request + // 3. parent of the request changed from having a parent to not having a parent. + // - similar to the previous one, we need to remove it from requests, and also + // remove the key from the current parent, and then add it into root_requests. + // 4. parent of the request changed to another parent, so we only need to update + // both folders + match (self.prev_parent, self.parent) { + (Some(a), Some(b)) if a == b => hac_store::collection::get_request_mut(self.key, |req, _| { + req.name.clone_from(&self.name); + req.method = self.method; + req.parent = self.parent; + }), + (None, None) => hac_store::collection::get_root_request_mut(self.key, |req, _| { + req.name.clone_from(&self.name); + req.method = self.method; + req.parent = self.parent; + }), + (None, Some(new_parent)) => { + let status = hac_store::collection::get_root_request(self.key, |_, status| status); + let is_selected = matches!(status, EntryStatus::Selected | EntryStatus::Both); + let mut req = hac_store::collection::remove_root_request(self.key); + req.name.clone_from(&self.name); + req.method = self.method; + req.parent = self.parent; + let key = hac_store::collection::push_request(req, Some(new_parent)); + hac_store::collection::set_hovered_request(Some((WhichSlab::Requests, key))); + if is_selected { + hac_store::collection::set_selected_request(Some((WhichSlab::Requests, key))); + } + } + (Some(_), None) => { + let status = hac_store::collection::get_request(self.key, |_, status| status); + let is_selected = matches!(status, EntryStatus::Selected | EntryStatus::Both); + let mut req = hac_store::collection::remove_request(self.key); + req.name.clone_from(&self.name); + req.method = self.method; + req.parent = self.parent; + let key = hac_store::collection::push_request(req, None); + hac_store::collection::set_hovered_request(Some((WhichSlab::RootRequests, key))); + if is_selected { + hac_store::collection::set_selected_request(Some((WhichSlab::RootRequests, key))); + } + } + (Some(curr_parent), Some(new_parent)) => { + hac_store::collection::get_request_mut(self.key, |req, _| { + req.name.clone_from(&self.name); + req.method = self.method; + req.parent = self.parent; + }); + hac_store::collection::get_folder_mut(curr_parent, |folder, _| { + folder.requests.retain(|r| *r != self.key) + }); + hac_store::collection::get_folder_mut(new_parent, |folder, _| folder.requests.push(self.key)); + } } - }); - if let Some((dir_id, _)) = self.parent_dir.as_ref() { - if let RequestKind::Nested(dir) = requests - .iter_mut() - .find(|req| req.get_id().eq(dir_id)) - .unwrap() - { - let request = self.request.as_ref().unwrap().clone(); - dir.requests - .write() - .unwrap() - .push(RequestKind::Single(request)); - } + hac_store::collection::rebuild_tree_layout(); + router_drop_dialog!(&self.messager, Routes::EditRequest.into()); } + _ => {} + }; - drop(store); - self.reset(); - return Ok(Some(RequestFormEvent::Confirm)); - } + Ok(None) + } - if let KeyCode::Esc = key_event.code { - self.reset(); - return Ok(Some(RequestFormEvent::Cancel)); - } + fn parent_selector_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { + match key_event.code { + KeyCode::Char('j') | KeyCode::Down => self.parent_listing.select_down(), + KeyCode::Char('k') | KeyCode::Up => self.parent_listing.select_up(), + KeyCode::Esc => self.form_step = FormStep::MainForm, + KeyCode::Enter => { + if hac_store::collection::has_folders() { + self.parent = Some(self.parent_listing.selected); + self.form_step = FormStep::MainForm; + self.parent_listing.reset() + } + } + _ => {} + }; - if let (KeyCode::Char('p'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { - self.parent_dir = None; - return Ok(Some(RequestFormEvent::Cancel)); - } + Ok(None) + } +} - if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { - self.reset(); - return Ok(None); +impl Renderable for EditRequestForm { + type Input = (); + type Output = (); + + fn draw(&mut self, frame: &mut Frame, _: Rect) -> anyhow::Result<()> { + make_overlay(self.colors.clone(), self.colors.normal.black, 0.15, frame); + + match self.form_step { + FormStep::MainForm => draw_main_form( + &self.name, + self.method, + self.parent, + self.focus, + &self.layout, + &self.colors, + frame, + ), + FormStep::ParentSelector => { + draw_parent_selector(&mut self.parent_listing, &self.layout, &self.colors, frame) + } } - match self.focused_field { - FormField::Name => match key_event.code { - KeyCode::Char(c) => { - self.request_name.push(c); - } - KeyCode::Backspace => { - self.request_name.pop(); - } - _ => {} - }, - FormField::Method => match key_event.code { - KeyCode::Char(c @ '1'..='5') => { - self.request_method = (c.to_digit(10).unwrap() as usize).sub(1).try_into()?; - } - KeyCode::Left => self.request_method = self.request_method.prev(), - KeyCode::Down => self.request_method = 4.try_into()?, - KeyCode::Up => self.request_method = 0.try_into()?, - KeyCode::Right => self.request_method = self.request_method.next(), - KeyCode::Char('h') => self.request_method = self.request_method.prev(), - KeyCode::Char('j') => self.request_method = 4.try_into()?, - KeyCode::Char('k') => self.request_method = 0.try_into()?, - KeyCode::Char('l') => self.request_method = self.request_method.next(), - _ => {} - }, - FormField::Parent => { - if let KeyCode::Char(' ') = key_event.code { - let mut store = self.collection_store.borrow_mut(); - let collection = store - .get_collection() - .expect("tried to select a parent without a collection"); - let collection = collection.borrow(); + Ok(()) + } - let Some(requests) = collection.requests.as_ref() else { - drop(store); - self.set_no_parent_timer(); - return Ok(None); - }; + fn data(&self, _requester: u8) -> Self::Output {} - let requests = requests.read().unwrap(); - if requests.is_empty() { - drop(store); - self.set_no_parent_timer(); - return Ok(None); - } + fn attach_navigator(&mut self, messager: Sender) { + self.messager = messager; + } +} - if requests - .iter() - .filter(|req| req.is_dir()) - .collect::>() - .is_empty() - { - drop(store); - self.set_no_parent_timer(); - return Ok(None); - } +impl Eventful for EditRequestForm { + type Result = Command; - store.push_overlay(CollectionViewerOverlay::SelectParentDir); - } - } + fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { + if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { + return Ok(Some(Command::Quit)); } + match self.form_step { + FormStep::MainForm => self.main_form_key_event(key_event)?, + FormStep::ParentSelector => self.parent_selector_key_event(key_event)?, + }; + Ok(None) } } diff --git a/hac-client/src/pages/collection_viewer/sidebar/mod.rs b/hac-client/src/pages/collection_viewer/sidebar/mod.rs new file mode 100755 index 0000000..b01c491 --- /dev/null +++ b/hac-client/src/pages/collection_viewer/sidebar/mod.rs @@ -0,0 +1,283 @@ +pub mod create_request_form; +pub mod delete_item_form; +pub mod edit_request_form; +mod request_form_layout; + +use crossterm::event::{KeyCode, KeyEvent}; +use hac_store::collection::{self, EntryStatus, ReqMethod, ReqTreeNode, WhichSlab}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Style, Styled, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::icons::Icons; +use crate::renderable::{Eventful, Renderable}; +use crate::{HacColors, HacConfig, MIN_WIDTH}; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum SidebarEvent { + ShowExtendedHint, + HideExtendedHint, + SelectPrev, + SelectNext, + RemoveSelection, + CreateRequest, + EditRequest, + DeleteItem, +} + +#[derive(Debug)] +pub struct Sidebar { + colors: HacColors, + config: HacConfig, + selected: bool, + focused: bool, + show_extended_hint: bool, +} + +impl Sidebar { + pub fn new(colors: HacColors, config: HacConfig) -> Self { + Self { + colors, + config, + selected: false, + focused: false, + show_extended_hint: false, + } + } + + pub fn focus(&mut self) { + self.focused = true; + } + + pub fn blur(&mut self) { + self.focused = false; + } + + pub fn select(&mut self) { + self.selected = true; + } + + pub fn deselect(&mut self) { + self.selected = false; + } + + fn draw_hint(&self, frame: &mut Frame, size: Rect) { + if !self.selected { + let hint = vec![ + "Enter".fg(self.colors.bright.green).bold(), + " - Select • ".fg(self.colors.bright.black), + "Tab".fg(self.colors.bright.green).bold(), + " - Next • ".fg(self.colors.bright.black), + "S-Tab".fg(self.colors.bright.green).bold(), + " - Prev".fg(self.colors.bright.black), + ]; + let size = Rect::new(size.x, size.height - 1, size.width, 1); + frame.render_widget(Line::from(hint).centered(), size); + return; + } + + if self.show_extended_hint { + let lines = vec![ + Line::from(vec![ + "j/k ↑/↓".fg(self.colors.bright.green).bold(), + " - Choose • ".fg(self.colors.bright.black), + "n".fg(self.colors.bright.green).bold(), + " - New request • ".fg(self.colors.bright.black), + "Enter".fg(self.colors.bright.green).bold(), + " - Select request".fg(self.colors.bright.black), + ]), + Line::from(vec![ + "?".fg(self.colors.bright.green).bold(), + " - Show less • ".fg(self.colors.bright.black), + "Ctrl c".fg(self.colors.bright.green).bold(), + " - Quit • ".fg(self.colors.bright.black), + "d".fg(self.colors.bright.green).bold(), + " - Delete item".fg(self.colors.bright.black), + ]), + Line::from(vec![ + "e".fg(self.colors.bright.green).bold(), + " - Edit • ".fg(self.colors.bright.black), + "Tab".fg(self.colors.bright.green).bold(), + " - Next • ".fg(self.colors.bright.black), + "S-Tab".fg(self.colors.bright.green).bold(), + " - Prev".fg(self.colors.bright.black), + ]), + ]; + let size = Rect::new(size.x, size.height - 3, size.width, 3); + let [_, size, _] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(MIN_WIDTH - 2), + Constraint::Fill(1), + ]) + .areas(size); + frame.render_widget(Paragraph::new(lines), size); + } else { + let hint = vec![ + "Enter".fg(self.colors.bright.green).bold(), + " - Select Request • ".fg(self.colors.bright.black), + "Esc".fg(self.colors.bright.green).bold(), + " - Deselect • ".fg(self.colors.bright.black), + "Tab".fg(self.colors.bright.green).bold(), + " - Next • ".fg(self.colors.bright.black), + "S-Tab".fg(self.colors.bright.green).bold(), + " - Prev • ".fg(self.colors.bright.black), + "?".fg(self.colors.bright.green).bold(), + " - Help".fg(self.colors.bright.black), + ]; + let size = Rect::new(size.x, size.height - 1, size.width, 1); + frame.render_widget(Line::from(hint).centered(), size); + } + } +} + +impl Renderable for Sidebar { + type Input = (); + type Output = (); + + fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()> { + let layout = hac_store::collection::tree_layout(); + let mut lines = vec![]; + + layout.nodes.into_iter().for_each(|node| match node { + ReqTreeNode::Req(key) => hac_store::collection::get_root_request(key, |req, status| { + let name = req.name.clone(); + let method = &req.method; + + let style = match status { + EntryStatus::None => Style::new().fg(self.colors.normal.white), + EntryStatus::Hovered => Style::new().fg(self.colors.bright.blue).underlined().italic(), + EntryStatus::Selected => Style::new().fg(self.colors.normal.red).bold(), + EntryStatus::Both => Style::new().fg(self.colors.normal.red).underlined().italic().bold(), + }; + + lines.push(Line::default().spans([colored_method(method, &self.colors), name.set_style(style)])); + }), + ReqTreeNode::Folder(folder_key, requests) => { + hac_store::collection::get_folder(folder_key, |folder, status| { + let folder_name = folder.name.clone(); + let style = match status { + EntryStatus::None => Style::new().fg(self.colors.normal.yellow).bold(), + _ => Style::new().fg(self.colors.normal.yellow).underlined().italic().bold(), + }; + let icon = match folder.collapsed { + true => Icons::FOLDER, + false => Icons::FOLDER_OPEN, + }; + let name = Line::default().spans([ + format!("{icon} ").bold().fg(self.colors.normal.yellow), + folder_name.set_style(style), + ]); + lines.push(name); + + if folder.collapsed { + return; + } + + for request in requests { + hac_store::collection::get_request(request, |req, status| { + let name = req.name.clone(); + let method = &req.method; + + let style = match status { + EntryStatus::None => Style::new().fg(self.colors.normal.white), + EntryStatus::Hovered => Style::new().fg(self.colors.bright.blue).underlined().italic(), + EntryStatus::Selected => Style::new().fg(self.colors.normal.red).bold(), + EntryStatus::Both => { + Style::new().fg(self.colors.normal.red).underlined().italic().bold() + } + }; + + lines.push(Line::default().spans([ + " ".repeat(self.config.borrow().tab_size).into(), + colored_method(method, &self.colors), + name.set_style(style), + ])); + }); + } + }); + } + }); + + let block_border = match (self.focused, self.selected) { + (true, false) => Style::default().fg(self.colors.bright.blue), + (true, true) => Style::default().fg(self.colors.normal.red), + (false, _) => Style::default().fg(self.colors.bright.black), + }; + let block = Block::default().borders(Borders::ALL).border_style(block_border); + frame.render_widget(block, size); + + let size = Rect::new(size.x + 1, size.y + 1, size.width - 2, size.height - 2); + frame.render_widget(Paragraph::new(lines), size); + + if self.selected || self.focused { + let size = frame.size(); + self.draw_hint(frame, size); + } + + Ok(()) + } + + fn data(&self, _requester: u8) -> Self::Output {} + + fn resize(&mut self, _new_size: Rect) {} +} + +impl Eventful for Sidebar { + type Result = SidebarEvent; + + fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { + match key_event.code { + KeyCode::Tab => return Ok(Some(SidebarEvent::SelectNext)), + KeyCode::BackTab => return Ok(Some(SidebarEvent::SelectPrev)), + KeyCode::Esc => return Ok(Some(SidebarEvent::RemoveSelection)), + KeyCode::Char('j') | KeyCode::Down => collection::hover_next(), + KeyCode::Char('k') | KeyCode::Up => collection::hover_prev(), + KeyCode::Char('e') => { + return collection::get_hovered_request(|entry| { + if let Some((which, _)) = entry { + match which { + WhichSlab::Requests | WhichSlab::RootRequests => { + return Ok(Some(SidebarEvent::EditRequest)) + } + WhichSlab::Folders => todo!(), + }; + }; + Ok(None) + }); + } + KeyCode::Char('d') => return Ok(Some(SidebarEvent::DeleteItem)), + KeyCode::Char('n') => return Ok(Some(SidebarEvent::CreateRequest)), + KeyCode::Char('?') => { + self.show_extended_hint = !self.show_extended_hint; + if self.show_extended_hint { + return Ok(Some(SidebarEvent::ShowExtendedHint)); + } else { + return Ok(Some(SidebarEvent::HideExtendedHint)); + } + } + KeyCode::Enter => { + if let Some((which, key)) = collection::get_hovered_request(|req| req) { + match which { + WhichSlab::Requests | WhichSlab::RootRequests => collection::select_request((which, key)), + WhichSlab::Folders => collection::toggle_dir(key), + } + }; + } + _ => (), + } + + Ok(None) + } +} + +fn colored_method(method: &ReqMethod, colors: &HacColors) -> Span<'static> { + match method { + ReqMethod::Get => format!("{method} ").fg(colors.normal.green).bold(), + ReqMethod::Post => format!("{method} ").fg(colors.normal.magenta).bold(), + ReqMethod::Put => format!("{method} ").fg(colors.normal.yellow).bold(), + ReqMethod::Patch => format!("{method} ").fg(colors.normal.orange).bold(), + ReqMethod::Delete => format!("{method} ").fg(colors.normal.red).bold(), + } +} diff --git a/hac-client/src/pages/collection_viewer/sidebar/request_form.rs b/hac-client/src/pages/collection_viewer/sidebar/request_form.rs deleted file mode 100644 index d3e670b..0000000 --- a/hac-client/src/pages/collection_viewer/sidebar/request_form.rs +++ /dev/null @@ -1,249 +0,0 @@ -use hac_core::collection::types::{Request, RequestMethod}; - -use crate::ascii::LOGO_ASCII; -use crate::pages::collection_viewer::collection_store::CollectionStore; -use crate::pages::collection_viewer::sidebar::select_request_parent::SelectRequestParent; -use crate::pages::input::Input; -use crate::pages::overlay::make_overlay; -use crate::pages::Renderable; - -use std::cell::RefCell; -use std::ops::{Add, Div, Sub}; -use std::rc::Rc; -use std::sync::{Arc, RwLock}; - -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::{Block, Borders, Paragraph}; -use ratatui::Frame; - -#[derive(Debug)] -pub enum RequestFormEvent { - Confirm, - Cancel, -} - -#[derive(Debug, PartialEq)] -pub enum FormField { - Name, - Method, - Parent, -} - -impl FormField { - pub fn next(&self) -> Self { - match self { - FormField::Name => FormField::Method, - FormField::Method => FormField::Parent, - FormField::Parent => FormField::Name, - } - } - - pub fn prev(&self) -> Self { - match self { - FormField::Name => FormField::Parent, - FormField::Method => FormField::Name, - FormField::Parent => FormField::Method, - } - } -} - -#[derive(Debug)] -pub struct RequestFormCreate; - -#[derive(Debug)] -pub struct RequestFormEdit; - -#[derive(Debug)] -pub struct RequestForm<'rf, State = RequestFormCreate> { - pub colors: &'rf hac_colors::Colors, - pub collection_store: Rc>, - - /// when we construct this component, we randomly chose one of our available - /// logo arts to display, for a little fun touch - pub logo_idx: usize, - /// the name of the current request being edited or created - pub request_name: String, - /// which method the request should have when finishing edition or creation - pub request_method: RequestMethod, - /// we store the parent dir uuid so its easier to find it and we dont need - /// lifetimes or to Rc our way to hell, along with it we also store the name - /// for displaying purposes - pub parent_dir: Option<(String, String)>, - /// which form field is currently focused, so we can direct interactions - /// accordingly - pub focused_field: FormField, - /// `request` is only used when editing a request so we can update it directly - pub request: Option>>, - /// parent selector is the form used to select a parent for the current edited - /// or created request - pub parent_selector: SelectRequestParent<'rf>, - /// when the user tries to select a parent for a given request but there are - /// no directories on the collection, we use this timer to show a message for - /// a short duration, alerting the user - pub no_available_parent_timer: Option, - - pub marker: std::marker::PhantomData, -} - -impl<'rf, State> RequestForm<'rf, State> { - pub fn reset(&mut self) { - self.request_name = String::default(); - self.request_method = RequestMethod::Get; - self.focused_field = FormField::Name; - self.parent_dir = None; - } - - pub fn set_no_parent_timer(&mut self) { - self.no_available_parent_timer = Some(std::time::Instant::now()); - } -} - -impl<'rf, State> Renderable for RequestForm<'rf, State> { - fn draw(&mut self, frame: &mut Frame, _: Rect) -> anyhow::Result<()> { - make_overlay(self.colors, self.colors.normal.black, 0.1, frame); - - let mut logo = LOGO_ASCII[self.logo_idx]; - let mut logo_size = logo.len() as u16; - // adding size of the form + spacing + hint - let total_size = logo_size.add(11).add(2); - - let size = frame.size(); - let mut size = Rect::new( - size.width.div(2).sub(32), - size.height - .div(2) - .saturating_sub(logo_size.div(2)) - .saturating_sub(6), - 65, - logo_size.add(12), - ); - - if total_size.ge(&frame.size().height) { - logo = &[]; - logo_size = 0; - size.height = 12; - size.y = frame.size().height.div(2).saturating_sub(5); - } - - if !logo.is_empty() { - let logo = logo - .iter() - .map(|line| Line::from(line.fg(self.colors.normal.red)).centered()) - .collect::>(); - - let logo_size = Rect::new(size.x, size.y, size.width, logo_size); - frame.render_widget(Paragraph::new(logo), logo_size); - } - - let mut name_input = Input::new(self.colors, "Name".into()); - let method_title = Paragraph::new("Method".fg(self.colors.normal.white)); - let hint = - "[Confirm: Enter] [Cancel: Esc] [Switch: Tab] [Select: Space] [Remove Parent: ]"; - let hint_size = hint.len() as u16; - let hint = Paragraph::new(hint.fg(self.colors.bright.black)).centered(); - - if self.focused_field.eq(&FormField::Name) { - name_input.focus(); - } - - let name_size = Rect::new(size.x, size.y.add(logo_size).add(1), size.width, 3); - let method_title_size = Rect::new(size.x, name_size.y.add(3), size.width, 1); - let methods_size = Rect::new(size.x, method_title_size.y.add(1), size.width, 3); - let parent_size = Rect::new(size.x, methods_size.y.add(3), size.width, 3); - let hint_size = Rect::new( - frame.size().width.div(2).saturating_sub(hint_size.div(2)), - parent_size.y.add(4), - hint_size, - 1, - ); - - let methods_items = Layout::default() - .direction(Direction::Horizontal) - .constraints((0..5).map(|_| Constraint::Length(13))) - .split(methods_size); - - let parent_name = if self.parent_dir.is_none() { - format!( - "{}None{}", - " ".repeat(parent_size.width.div(2).sub(2).into()), - " ".repeat(parent_size.width.div(2).sub(2).into()) - ) - .fg(self.colors.bright.black) - } else { - format!( - "{}{}{}", - " ".repeat(parent_size.width.div(2).sub(2).into()), - self.parent_dir.as_ref().unwrap().1.clone(), - " ".repeat(parent_size.width.div(2).sub(2).into()) - ) - .fg(self.colors.normal.white) - }; - let parent = Paragraph::new(parent_name).centered().block( - Block::default() - .title("Parent".fg(self.colors.normal.white)) - .borders(Borders::ALL) - .fg(if self.focused_field.eq(&FormField::Parent) { - self.colors.normal.red - } else { - self.colors.bright.black - }), - ); - - for (idx, method) in RequestMethod::iter().enumerate() { - let border_color = match (&self.request_method, &self.focused_field) { - (m, FormField::Method) if m.eq(method) => self.colors.normal.red, - (m, _) if m.eq(method) => self.colors.bright.blue, - _ => self.colors.bright.black, - }; - let method = Paragraph::new(Line::from(vec![ - format!(" {}", idx.add(1)).fg(self.colors.bright.black), - format!( - " {}{}", - method, - " ".repeat(10.sub(method.to_string().len())) - ) - .fg(self.colors.normal.white), - ])) - .block(Block::default().borders(Borders::ALL).fg(border_color)); - frame.render_widget(method, methods_items[idx]); - } - - frame.render_stateful_widget(name_input, name_size, &mut self.request_name); - frame.render_widget(method_title, method_title_size); - frame.render_widget(parent, parent_size); - - if self - .no_available_parent_timer - .is_some_and(|timer| timer.elapsed().as_secs().le(&3)) - { - let warning = Paragraph::new( - "No available directory to select as a parent".fg(self.colors.normal.red), - ) - .centered(); - frame.render_widget(warning, hint_size); - } else { - frame.render_widget(hint, hint_size); - } - - if self - .no_available_parent_timer - .is_some_and(|timer| timer.elapsed().as_secs().gt(&3)) - { - self.no_available_parent_timer = None; - } - - if self.focused_field.eq(&FormField::Name) { - frame.set_cursor( - name_size - .x - .add(self.request_name.chars().count() as u16) - .add(1), - name_size.y.add(1), - ); - } - - Ok(()) - } -} diff --git a/hac-client/src/pages/collection_viewer/sidebar/request_form_layout.rs b/hac-client/src/pages/collection_viewer/sidebar/request_form_layout.rs new file mode 100644 index 0000000..062cd59 --- /dev/null +++ b/hac-client/src/pages/collection_viewer/sidebar/request_form_layout.rs @@ -0,0 +1,251 @@ +use std::rc::Rc; + +use hac_store::collection::ReqMethod; +use hac_store::slab::Key; +use ratatui::layout::{Constraint, Flex, Layout, Margin, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Frame; + +use crate::ascii::LOGO_ASCII; +use crate::components::blending_list::BlendingList; +use crate::components::input::Input; +use crate::{HacColors, MIN_HEIGHT, MIN_WIDTH}; + +#[derive(Debug)] +pub enum FormStep { + MainForm, + ParentSelector, +} + +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] +pub enum FieldFocus { + #[default] + Name, + Methods, + Parent, +} + +impl FieldFocus { + pub fn next(&mut self) { + match self { + FieldFocus::Name => *self = FieldFocus::Methods, + FieldFocus::Methods => *self = FieldFocus::Parent, + FieldFocus::Parent => *self = FieldFocus::Name, + } + } + + pub fn prev(&mut self) { + match self { + FieldFocus::Name => *self = FieldFocus::Parent, + FieldFocus::Methods => *self = FieldFocus::Name, + FieldFocus::Parent => *self = FieldFocus::Methods, + } + } +} + +#[derive(Debug)] +pub struct ReqFormLayout { + pub name: Rect, + pub hint: Rect, + pub logo: Rect, + pub parent: Rect, + pub methods: Rc<[Rect]>, + pub parent_listing: Rect, + pub parent_hint: Rect, +} + +pub fn make_contextual_hint(focus: FieldFocus, colors: &HacColors) -> Vec { + match focus { + FieldFocus::Name => vec![ + "Enter".fg(colors.bright.green).bold(), + " - Confirm • ".fg(colors.bright.black), + "Esc".fg(colors.bright.green).bold(), + " - Cancel • ".fg(colors.bright.black), + "Tab".fg(colors.bright.green).bold(), + " - Next • ".fg(colors.bright.black), + "S-Tab".fg(colors.bright.green).bold(), + " - Prev • ".fg(colors.bright.black), + "Ctrl p".fg(colors.bright.green).bold(), + " - Parent".fg(colors.bright.black), + ], + FieldFocus::Methods => vec![ + "Enter".fg(colors.bright.green).bold(), + " - Confirm • ".fg(colors.bright.black), + "Esc".fg(colors.bright.green).bold(), + " - Cancel • ".fg(colors.bright.black), + "Tab".fg(colors.bright.green).bold(), + " - Next • ".fg(colors.bright.black), + "S-Tab".fg(colors.bright.green).bold(), + " - Prev • ".fg(colors.bright.black), + "1-5".fg(colors.bright.green).bold(), + " - Method".fg(colors.bright.black), + ], + FieldFocus::Parent => vec![ + "Enter".fg(colors.bright.green).bold(), + " - Confirm • ".fg(colors.bright.black), + "Esc".fg(colors.bright.green).bold(), + " - Cancel • ".fg(colors.bright.black), + "Ctrl p".fg(colors.bright.green).bold(), + " - Parent • ".fg(colors.bright.black), + "Backspace".fg(colors.bright.green).bold(), + " - Remove parent".fg(colors.bright.black), + ], + } +} + +pub fn draw_main_form( + name: &str, + method: ReqMethod, + parent: Option, + focus: FieldFocus, + layout: &ReqFormLayout, + colors: &HacColors, + frame: &mut Frame, +) { + let border_style = match focus == FieldFocus::Name { + true => Style::new().fg(colors.bright.red), + false => Style::new().fg(colors.bright.black), + }; + let label = String::from("Request Name"); + let name_input = Input::new(Some(name), Some(&label), colors.clone()) + .border_style(border_style) + .value_style(Style::default().fg(colors.normal.white)) + .label_style(Style::default().fg(colors.bright.black)); + + let logo = Paragraph::new( + LOGO_ASCII + .iter() + .map(|line| Line::from(line.to_string()).fg(colors.bright.red).centered()) + .collect::>(), + ); + + for (idx, m) in ReqMethod::iter().enumerate() { + let selected = m == method; + let number_color = match selected { + true => colors.bright.blue, + false => colors.bright.black, + }; + let area = layout.methods[idx]; + let method = m.to_string(); + let remaining_width = area.width as usize - 3 - method.len(); + let left_pad = remaining_width / 2; + + let parts = vec![ + (idx + 1).to_string().fg(number_color), + " ".repeat(left_pad).into(), + method.fg(number_color), + ]; + + let mut block = Block::new() + .borders(Borders::ALL) + .border_style(Style::new().fg(colors.bright.black)); + if let FieldFocus::Methods = focus { + block = block.border_style(Style::new().fg(colors.bright.red)); + } + if selected { + block = block.border_style(Style::new().fg(colors.bright.blue)); + } + + frame.render_widget(Paragraph::new(Line::from(parts)).block(block), area); + } + + let hint = make_contextual_hint(focus, colors); + let mut parent_name = "No Parent".to_string(); + if let Some(parent) = parent { + hac_store::collection::get_folder(parent, |folder, _| parent_name.clone_from(&folder.name)); + }; + + let parent_color = if focus == FieldFocus::Parent { colors.bright.red } else { colors.bright.black }; + + let parent = + Paragraph::new(Line::from(parent_name).centered()).block(Block::new().borders(Borders::ALL).fg(parent_color)); + + frame.render_widget(name_input, layout.name); + frame.render_widget(logo, layout.logo); + frame.render_widget(Clear, layout.parent); + frame.render_widget(parent, layout.parent); + frame.render_widget(Line::from(hint).centered(), layout.hint); + + if let FieldFocus::Name = focus { + frame.set_cursor(layout.name.x + 1 + name.chars().count() as u16, layout.name.y + 2); + } +} + +pub fn draw_parent_selector( + parent_listing: &mut BlendingList, + layout: &ReqFormLayout, + colors: &HacColors, + frame: &mut Frame, +) { + let logo = Paragraph::new( + LOGO_ASCII + .iter() + .map(|line| Line::from(line.to_string()).fg(colors.bright.red).centered()) + .collect::>(), + ); + + let mut folders = vec![]; + hac_store::collection::folders(|folder| folders.push(folder.name.clone())); + parent_listing.draw_with(frame, folders.iter(), |name| name, layout.parent_listing); + + let hint = vec![ + "Enter".fg(colors.bright.green).bold(), + " - Confirm • ".fg(colors.bright.black), + "Esc".fg(colors.bright.green).bold(), + " - Cancel • ".fg(colors.bright.black), + "j/k ↑/↓".fg(colors.normal.green), + " - Choose".fg(colors.bright.black), + ]; + + frame.render_widget(Line::from(hint).centered(), layout.parent_hint); + frame.render_widget(logo, layout.logo); +} + +pub fn build_req_form_layout(area: Rect) -> ReqFormLayout { + let [_, form, _] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(MIN_WIDTH), Constraint::Fill(1)]) + .flex(Flex::Center) + .areas(area); + + let [_, form, _] = Layout::vertical([Constraint::Fill(1), Constraint::Length(MIN_HEIGHT), Constraint::Fill(1)]) + .flex(Flex::End) + .areas(form); + + let form = form.inner(&Margin::new(2, 0)); + + let [logo, _, name, _, methods, _, parent, hint] = Layout::vertical([ + Constraint::Length(LOGO_ASCII.len() as u16), + Constraint::Length(1), + Constraint::Length(4), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .flex(Flex::Center) + .areas(form); + + let [_, _, parent_listing, _, parent_hint] = Layout::vertical([ + Constraint::Length(LOGO_ASCII.len() as u16), + Constraint::Length(1), + Constraint::Length(13), + Constraint::Length(1), + Constraint::Length(1), + ]) + .flex(Flex::Center) + .areas(form); + + let methods = Layout::horizontal((0..ReqMethod::size()).map(|_| Constraint::Fill(1))).split(methods); + + ReqFormLayout { + name, + hint, + logo, + methods, + parent, + parent_listing, + parent_hint, + } +} diff --git a/hac-client/src/pages/collection_viewer/sidebar/select_request_parent.rs b/hac-client/src/pages/collection_viewer/sidebar/select_request_parent.rs deleted file mode 100644 index 3749a4a..0000000 --- a/hac-client/src/pages/collection_viewer/sidebar/select_request_parent.rs +++ /dev/null @@ -1,179 +0,0 @@ -use crate::ascii::LOGO_ASCII; -use crate::pages::collection_viewer::collection_store::CollectionStore; -use crate::pages::overlay::make_overlay; -use crate::pages::{Eventful, Renderable}; - -use std::cell::RefCell; -use std::ops::{Add, Div, Mul, Sub}; -use std::rc::Rc; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use rand::Rng; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Paragraph; -use ratatui::Frame; - -#[derive(Debug, PartialEq, Eq, Clone)] -pub enum SelectRequestParentEvent { - /// user selected a directory, so we send back the id of the directory to be - /// linked to the request. - Confirm(String), - /// user canceled the parent selection, so nothing will be linked to the - /// request - Cancel, -} - -#[derive(Debug)] -pub struct SelectRequestParent<'srp> { - colors: &'srp hac_colors::Colors, - collection_store: Rc>, - selected_dir: usize, - logo_idx: usize, - scroll: usize, -} - -impl<'srp> SelectRequestParent<'srp> { - pub fn new( - colors: &'srp hac_colors::Colors, - collection_store: Rc>, - ) -> Self { - SelectRequestParent { - colors, - collection_store, - selected_dir: 0, - logo_idx: rand::thread_rng().gen_range(0..LOGO_ASCII.len()), - scroll: 0, - } - } -} - -impl Renderable for SelectRequestParent<'_> { - fn draw(&mut self, frame: &mut Frame, _: Rect) -> anyhow::Result<()> { - make_overlay(self.colors, self.colors.normal.black, 0.1, frame); - - let store = self.collection_store.borrow(); - let collection = store - .get_collection() - .expect("trying to select a parent directory without a collection"); - - let mut directories = vec![]; - if let Some(ref requests) = collection.borrow().requests { - requests - .read() - .unwrap() - .iter() - .filter(|req| req.is_dir()) - .for_each(|dir| directories.push(dir.get_name())); - }; - - let mut logo = LOGO_ASCII[self.logo_idx]; - let size = frame.size(); - let mut logo_size = logo.len() as u16; - - // if the logo makes the screen have 10 or less height, we hide it - if size.height.sub(logo_size).le(&10) { - logo = &[]; - logo_size = 0; - } - - let size = Rect::new( - size.width.div(2).saturating_sub(25), - size.y.add(4), - 50, - size.height, - ); - - if !logo.is_empty() { - let logo_size = Rect::new(size.x, size.y, size.width, logo_size); - let logo = logo - .iter() - .map(|line| Line::from(line.fg(self.colors.normal.red)).centered()) - .collect::>(); - - frame.render_widget(Paragraph::new(logo), logo_size); - } - - let item_height = 2; - let remaining_space = size.height.sub(logo_size).sub(1); - let amount_on_view = remaining_space.div(item_height); - let dirs_start_y = logo_size.add(3); - - let header = Paragraph::new("Available directories".fg(self.colors.normal.yellow).bold()); - let header_size = Rect::new(size.x, size.y.add(logo_size).add(1), size.width, 2); - frame.render_widget(header, header_size); - - for (idx, dir) in directories - .into_iter() - .enumerate() - .skip(self.scroll) - .take(amount_on_view.into()) - { - let foreground = if self.selected_dir.eq(&idx) { - self.colors.normal.red - } else { - self.colors.normal.white - }; - let dir_size = Rect::new( - size.x, - size.y.add(dirs_start_y).add(idx.mul(2) as u16), - size.width, - 2, - ); - let dir = Paragraph::new(dir.fg(foreground)); - frame.render_widget(dir, dir_size); - } - - Ok(()) - } -} - -impl Eventful for SelectRequestParent<'_> { - type Result = SelectRequestParentEvent; - - fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { - if let (KeyCode::Char('c'), KeyModifiers::CONTROL) = (key_event.code, key_event.modifiers) { - return Ok(Some(SelectRequestParentEvent::Cancel)); - } - - let store = self.collection_store.borrow(); - let collection = store - .get_collection() - .expect("trying to select a parent directory without a collection"); - - let mut directories = vec![]; - if let Some(ref requests) = collection.borrow().requests { - requests - .read() - .unwrap() - .iter() - .filter(|req| req.is_dir()) - .for_each(|dir| directories.push(dir.get_id())) - }; - let total_dirs = directories.len(); - - match key_event.code { - KeyCode::Enter => { - let (_, dir_id) = directories - .into_iter() - .enumerate() - .find(|(idx, _)| idx.eq(&self.selected_dir)) - .unwrap(); - return Ok(Some(SelectRequestParentEvent::Confirm(dir_id))); - } - KeyCode::Esc => { - return Ok(Some(SelectRequestParentEvent::Cancel)); - } - KeyCode::Down | KeyCode::Tab | KeyCode::Char('j') => { - self.selected_dir = usize::min(self.selected_dir.add(1), total_dirs.sub(1)); - } - KeyCode::Up | KeyCode::BackTab | KeyCode::Char('k') => { - self.selected_dir = self.selected_dir.saturating_sub(1); - } - _ => {} - } - - Ok(None) - } -} diff --git a/hac-client/src/pages/confirm_popup.rs b/hac-client/src/pages/confirm_popup.rs deleted file mode 100755 index c4801b8..0000000 --- a/hac-client/src/pages/confirm_popup.rs +++ /dev/null @@ -1,79 +0,0 @@ -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::{Style, Stylize}; -use ratatui::text::Line; -use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap}; - -pub struct ConfirmPopup<'a> { - message: String, - colors: &'a hac_colors::Colors, -} - -impl<'a> ConfirmPopup<'a> { - pub fn new(message: String, colors: &'a hac_colors::Colors) -> Self { - ConfirmPopup { message, colors } - } - - fn build_popup(&self) -> Paragraph<'_> { - let lines = vec![ - self.message.clone().fg(self.colors.normal.yellow).into(), - "".into(), - Line::from(vec![ - "(y)es".fg(self.colors.normal.green), - " ".into(), - "(n)o".fg(self.colors.normal.red), - ]) - .centered(), - ]; - Paragraph::new(lines).wrap(Wrap { trim: true }).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(self.colors.bright.black)) - .padding(Padding::new(2, 2, 1, 1)) - .bg(self.colors.normal.black), - ) - } -} - -impl Widget for ConfirmPopup<'_> { - fn render(self, size: Rect, buf: &mut Buffer) - where - Self: Sized, - { - Clear.render(size, buf); - let popup = self.build_popup(); - popup.render(size, buf); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_build_with_correct_message() { - let colors = hac_colors::Colors::default(); - let popup = ConfirmPopup::new("my confirmation message".into(), &colors); - let lines = vec![ - "my confirmation message".fg(colors.normal.yellow).into(), - "".into(), - Line::from(vec![ - "(y)es".fg(colors.normal.green), - " ".into(), - "(n)o".fg(colors.normal.red), - ]) - .centered(), - ]; - let expected = Paragraph::new(lines).wrap(Wrap { trim: true }).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(colors.bright.black)) - .padding(Padding::new(2, 2, 1, 1)) - .bg(colors.normal.black), - ); - - let content = popup.build_popup(); - - assert_eq!(expected, content); - } -} diff --git a/hac-client/src/pages/error_popup.rs b/hac-client/src/pages/error_popup.rs deleted file mode 100755 index b0b7c8c..0000000 --- a/hac-client/src/pages/error_popup.rs +++ /dev/null @@ -1,130 +0,0 @@ -use ratatui::buffer::Buffer; -use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect}; -use ratatui::style::{Style, Stylize}; -use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap}; - -#[derive(Debug, PartialEq)] -pub struct ErrorPopupLayout { - message_pane: Rect, - confirmation_pane: Rect, -} - -pub struct ErrorPopup<'a> { - message: String, - colors: &'a hac_colors::Colors, -} - -impl<'a> ErrorPopup<'a> { - pub fn new(message: String, colors: &'a hac_colors::Colors) -> Self { - ErrorPopup { message, colors } - } - - fn build_popup(&self) -> (Paragraph<'_>, Paragraph<'_>) { - let message = Paragraph::new(self.message.clone().fg(self.colors.normal.red)) - .wrap(Wrap { trim: true }); - - let confirmation = Paragraph::new("(O)k".fg(self.colors.normal.green).into_centered_line()) - .wrap(Wrap { trim: true }); - - (message, confirmation) - } - - fn layout(&self, size: &Rect) -> ErrorPopupLayout { - let size = Rect::new( - size.x + 2, - size.y + 2, - size.width.saturating_sub(4), - size.height.saturating_sub(4), - ); - - let [message_pane, confirmation_pane] = Layout::default() - .direction(Direction::Vertical) - .flex(Flex::SpaceBetween) - .constraints([Constraint::Fill(1), Constraint::Length(1)]) - .areas(size); - - ErrorPopupLayout { - message_pane, - confirmation_pane, - } - } - - fn build_container(&self) -> Block<'_> { - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(self.colors.bright.black)) - .padding(Padding::new(2, 2, 1, 1)) - .bg(self.colors.normal.black) - } -} - -impl Widget for ErrorPopup<'_> { - fn render(self, size: Rect, buf: &mut Buffer) - where - Self: Sized, - { - Clear.render(size, buf); - let layout = self.layout(&size); - let (message, confirmation) = self.build_popup(); - let full_block = self.build_container(); - - full_block.render(size, buf); - message.render(layout.message_pane, buf); - confirmation.render(layout.confirmation_pane, buf); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_build_with_correct_message() { - let colors = hac_colors::Colors::default(); - let popup = ErrorPopup::new("my error message".into(), &colors); - - let (message, confirmation) = popup.build_popup(); - - assert_eq!( - message, - Paragraph::new("my error message".fg(colors.normal.red)).wrap(Wrap { trim: true }) - ); - - assert_eq!( - confirmation, - Paragraph::new("(O)k".fg(colors.normal.green).into_centered_line()) - .wrap(Wrap { trim: true }) - ); - } - - #[test] - fn test_build_layout_correctly() { - let colors = hac_colors::Colors::default(); - let popup = ErrorPopup::new("my error message".into(), &colors); - let rect = Rect::new(0, 0, 10, 10); - let expected = ErrorPopupLayout { - message_pane: Rect::new(2, 2, 6, 5), - confirmation_pane: Rect::new(2, 7, 6, 1), - }; - - let layout = popup.layout(&rect); - - assert_eq!(layout, expected); - } - - #[test] - fn test_build_container_correctly() { - let colors = hac_colors::Colors::default(); - let popup = ErrorPopup::new("my error message".into(), &colors); - - let expected = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(colors.bright.black)) - .padding(Padding::new(2, 2, 1, 1)) - .bg(colors.normal.black); - - let block = popup.build_container(); - - assert_eq!(expected, block); - } -} diff --git a/hac-client/src/pages/input.rs b/hac-client/src/pages/input.rs index 7abdef3..00b077b 100755 --- a/hac-client/src/pages/input.rs +++ b/hac-client/src/pages/input.rs @@ -35,7 +35,7 @@ impl<'a> Input<'a> { self.focused = true; } - fn build_input(&self, value: String, size: Rect) -> Paragraph<'_> { + fn build_input(&self, value: String, _: Rect) -> Paragraph<'_> { let border_color = if self.focused { Style::default().fg(self.colors.normal.red) } else { @@ -50,11 +50,7 @@ impl<'a> Input<'a> { (value, color) }; - let without_space = format!( - "{value}{}", - " ".repeat(size.width.saturating_sub(value.len() as u16) as usize) - ); - Paragraph::new(without_space) + Paragraph::new(value) .block( Block::default() .title(self.name.clone()) diff --git a/hac-client/src/pages/mod.rs b/hac-client/src/pages/mod.rs new file mode 100755 index 0000000..f68a874 --- /dev/null +++ b/hac-client/src/pages/mod.rs @@ -0,0 +1,5 @@ +pub mod collection_list; +pub mod collection_viewer; +pub mod input; +pub mod overlay; +pub mod terminal_too_small; diff --git a/hac-client/src/pages/overlay.rs b/hac-client/src/pages/overlay.rs index 812770d..a6fad21 100755 --- a/hac-client/src/pages/overlay.rs +++ b/hac-client/src/pages/overlay.rs @@ -1,25 +1,10 @@ -use ratatui::layout::Rect; -use ratatui::style::{Color, Stylize}; -use ratatui::text::Line; -use ratatui::widgets::Paragraph; +use ratatui::style::Color; use ratatui::Frame; -use crate::utils::blend_colors_multiply; +use crate::utils::alpha_blend_multiply; +use crate::HacColors; -/// draws a fullscreen overlay with the given fill text, many pages uses this to display -/// "floating" information -pub fn draw_overlay(colors: &hac_colors::Colors, size: Rect, fill_text: &str, frame: &mut Frame) { - let lines: Vec> = vec![fill_text.repeat(size.width.into()).into(); size.height.into()]; - - let overlay = Paragraph::new(lines) - .fg(colors.primary.hover) - .bg(colors.primary.background) - .bold(); - - frame.render_widget(overlay, size); -} - -pub fn make_overlay(colors: &hac_colors::Colors, color: Color, alpha: f32, frame: &mut Frame) { +pub fn make_overlay(colors: HacColors, color: Color, alpha: f32, frame: &mut Frame) { let buffer = frame.buffer_mut(); let cells = &mut buffer.content; @@ -27,7 +12,7 @@ pub fn make_overlay(colors: &hac_colors::Colors, color: Color, alpha: f32, frame let cell_fg = cell.style().fg.unwrap_or(colors.normal.white); let cell_bg = cell.style().bg.unwrap_or(colors.primary.background); - cell.set_fg(blend_colors_multiply(cell_fg, color, alpha)); - cell.set_bg(blend_colors_multiply(cell_bg, color, alpha)); + cell.set_fg(alpha_blend_multiply(cell_fg, color, alpha)); + cell.set_bg(alpha_blend_multiply(cell_bg, color, alpha)); }); } diff --git a/hac-client/src/pages/terminal_too_small.rs b/hac-client/src/pages/terminal_too_small.rs index 8edcd1f..0b7eeab 100755 --- a/hac-client/src/pages/terminal_too_small.rs +++ b/hac-client/src/pages/terminal_too_small.rs @@ -1,26 +1,31 @@ -use crate::pages::Renderable; - use ratatui::layout::{Alignment, Constraint, Direction, Flex, Layout, Rect}; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::{Paragraph, Wrap}; use ratatui::Frame; +use crate::renderable::Renderable; +use crate::{HacColors, MIN_HEIGHT, MIN_WIDTH}; + /// `TerminalTooSmall` as the name suggests is a screen rendered by the /// `screen_manager` when the terminal gets smaller than a certain threshold, /// this page will display over everything and will automatically be hidden /// when the terminal gets bigger than said threshold -pub struct TerminalTooSmall<'tts> { - colors: &'tts hac_colors::Colors, +#[derive(Debug)] +pub struct TerminalTooSmall { + colors: HacColors, } -impl<'a> TerminalTooSmall<'a> { - pub fn new(colors: &'a hac_colors::Colors) -> Self { +impl TerminalTooSmall { + pub fn new(colors: HacColors) -> Self { TerminalTooSmall { colors } } } -impl Renderable for TerminalTooSmall<'_> { +impl Renderable for TerminalTooSmall { + type Input = (); + type Output = (); + fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()> { let layout = build_layout(size); @@ -33,7 +38,11 @@ impl Renderable for TerminalTooSmall<'_> { ]); let empty = Line::from(" "); let hint = Line::from("Minimum size needed:".bold().fg(self.colors.bright.black)); - let min_size = Line::from("Width = 80 Height = 22".bold().fg(self.colors.bright.black)); + let min_size = Line::from( + format!("Width = {MIN_WIDTH} Height = {MIN_HEIGHT}") + .bold() + .fg(self.colors.bright.black), + ); let text = Paragraph::new(vec![lines, curr_size, empty, hint, min_size]) .wrap(Wrap { trim: true }) @@ -48,15 +57,13 @@ impl Renderable for TerminalTooSmall<'_> { // we purposefully don't do nothing here, as this page automatically adapts to the // size of the window when rendering fn resize(&mut self, _new_size: Rect) {} + + fn data(&self, _requester: u8) -> Self::Output {} } fn build_layout(size: Rect) -> Rect { Layout::default() - .constraints([ - Constraint::Fill(1), - Constraint::Length(5), - Constraint::Fill(1), - ]) + .constraints([Constraint::Fill(1), Constraint::Length(5), Constraint::Fill(1)]) .direction(Direction::Vertical) .flex(Flex::Center) .split(size)[1] diff --git a/hac-client/src/pages/under_construction.rs b/hac-client/src/pages/under_construction.rs deleted file mode 100755 index 7722656..0000000 --- a/hac-client/src/pages/under_construction.rs +++ /dev/null @@ -1,65 +0,0 @@ -use crate::ascii::UNDER_CONSTRUCTION; -use crate::pages::Renderable; - -use std::ops::{Add, Sub}; - -use ratatui::{layout::Rect, style::Stylize, text::Line, widgets::Paragraph, Frame}; - -pub struct UnderConstruction<'uc> { - colors: &'uc hac_colors::colors::Colors, -} - -impl<'uc> UnderConstruction<'uc> { - pub fn new(colors: &'uc hac_colors::colors::Colors) -> Self { - UnderConstruction { colors } - } -} - -impl Renderable for UnderConstruction<'_> { - fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()> { - let icon_lines = UNDER_CONSTRUCTION - .iter() - .map(|line| Line::from(line.to_string()).fg(self.colors.normal.red)) - .collect::>(); - - let icon_height = icon_lines.len(); - let icon_half_height = icon_height.div_ceil(2); - let half_height = size.height.div_ceil(2); - let starting_y = half_height - .saturating_sub(icon_half_height as u16) - .saturating_sub(1); - - let icon_size = Rect::new( - size.x, - size.y.add(starting_y), - size.width, - icon_height as u16, - ); - - let message = - Line::from("Hold on, we're cooking up something new!").fg(self.colors.normal.red); - - let message_size = Rect::new( - size.x, - size.y.add(starting_y).add(icon_height as u16).add(1), - size.width, - 1, - ); - - if icon_height >= (size.height - 3).into() { - let rect = Rect::new( - size.x, - size.y.add(size.height.div_ceil(2).sub(1)), - size.width, - 1, - ); - frame.render_widget(Paragraph::new(message).centered(), rect); - return Ok(()); - } - - // frame.render_widget(Paragraph::new(icon_lines).centered(), icon_size); - // frame.render_widget(Paragraph::new(message).centered(), message_size); - - Ok(()) - } -} diff --git a/hac-client/src/pages.rs b/hac-client/src/renderable.rs old mode 100755 new mode 100644 similarity index 63% rename from hac-client/src/pages.rs rename to hac-client/src/renderable.rs index 708c2da..2e7e8e8 --- a/hac-client/src/pages.rs +++ b/hac-client/src/renderable.rs @@ -1,22 +1,32 @@ -pub mod collection_dashboard; -pub mod collection_viewer; -pub mod confirm_popup; -pub mod error_popup; -pub mod input; -pub mod overlay; -mod spinner; -pub mod terminal_too_small; -mod under_construction; +use std::sync::mpsc::Sender; -use crate::event_pool::Event; use crossterm::event::KeyEvent; use hac_core::command::Command; -use ratatui::{layout::Rect, Frame}; -use tokio::sync::mpsc::UnboundedSender; +use ratatui::layout::Rect; +use ratatui::Frame; + +use crate::event_pool::Event; +use crate::router::RouterMessage; -/// A `Page` is anything that is a top level page and can be drawn to the screen +/// A `Renderable` is anything that is a top level page and can be drawn to the screen pub trait Renderable { - fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()>; + type Input: 'static; + type Output: 'static; + + #[allow(unused_variables)] + fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()> { + Ok(()) + } + + /// when navigating to a new route, you might need to pass in some data, here you do it + #[allow(unused_variables)] + fn update(&mut self, data: Self::Input) {} + + fn data(&self, requester: u8) -> Self::Output; + + /// each route can, (and must) have a navigator, this is how you attach one + #[allow(unused_variables)] + fn attach_navigator(&mut self, messager: Sender) {} /// pages need to adapt to change of sizes on the application, this function is called /// by the top level event loop whenever a resize event is produced @@ -26,13 +36,16 @@ pub trait Renderable { /// register a page to be a command handler, which means this page will now receive /// commands from the channel to handle whatever the commands it is interested into #[allow(unused_variables)] - fn register_command_handler(&mut self, sender: UnboundedSender) -> anyhow::Result<()> { + fn register_command_handler(&mut self, sender: Sender) {} + + #[allow(unused_variables)] + fn handle_command(&mut self, command: Command) -> anyhow::Result<()> { Ok(()) } /// tick is a bigger interval than the one used by the render cycle, it is mainly used /// for actions that rely on time, such as syncing changes to disk - fn handle_tick(&mut self) -> anyhow::Result<()> { + fn tick(&mut self) -> anyhow::Result<()> { Ok(()) } } diff --git a/hac-client/src/router.rs b/hac-client/src/router.rs new file mode 100644 index 0000000..e0de1fc --- /dev/null +++ b/hac-client/src/router.rs @@ -0,0 +1,347 @@ +use std::any::Any; +use std::collections::HashMap; +use std::fmt::Debug; +use std::sync::mpsc::{Receiver, Sender}; + +use crossterm::event::KeyEvent; +use hac_core::command::Command; +use ratatui::layout::Rect; +use ratatui::Frame; + +use crate::pages::terminal_too_small::TerminalTooSmall; +use crate::renderable::{Eventful, Renderable}; +use crate::{HacColors, MIN_HEIGHT, MIN_WIDTH}; + +type Key = u8; + +/// This is how routes communicate with the router how then want to navigate +/// between screens, a router can have any number of dialogs, routes and +/// nested routers. +#[derive(Debug)] +pub enum Navigate { + /// Change the current route to a new route, or open a dialog if the key matches a + /// dialog rather than a route + /// Navigating to a route from a dialog will close that dialog + To(Key), + /// Go back in the navigation history, this completely wipes the current router history, + /// and close every dialog thats visible + Back, +} + +#[macro_export] +macro_rules! router_add_dialog { + ($messager:expr, $route:expr, $renderable:expr) => { + $messager + .send(RouterMessage::AddDialog($route.into(), Box::new($renderable))) + .expect("failed to send router message"); + }; +} + +#[macro_export] +macro_rules! router_drop_dialog { + ($messager:expr, $dialog:expr) => { + $messager + .send(RouterMessage::DelDialog($dialog)) + .expect("failed to send router message"); + }; +} + +#[macro_export] +macro_rules! router_navigate_to { + ($messager:expr, $route:expr) => { + $messager + .send(RouterMessage::Navigate($crate::router::Navigate::To($route.into()))) + .expect("failed to send router message"); + }; +} + +#[macro_export] +macro_rules! router_navigate_back { + ($messager:expr) => { + $messager + .send(RouterMessage::Navigate($crate::router::Navigate::Back)) + .expect("failed to send router message"); + }; +} + +pub trait AnyCommand {} +impl AnyCommand for Command {} + +pub trait AnyRenderable: Debug { + type Ev: AnyCommand; + + fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()>; + fn data(&self, requester: Key) -> Box; + fn update(&mut self, input: Box); + fn attach_navigator(&mut self, messager: Sender); + fn resize(&mut self, new_size: Rect); + fn register_command_handler(&mut self, sender: Sender); + fn handle_command(&mut self, command: Command) -> anyhow::Result<()>; + fn tick(&mut self) -> anyhow::Result<()>; + fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result>; +} + +impl + Debug> AnyRenderable for T { + type Ev = K; + + fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()> { + ::draw(self, frame, size) + } + + fn data(&self, requester: Key) -> Box { + Box::new(::data(self, requester)) + } + + fn update(&mut self, input: Box) { + let input = input.downcast::().expect("wrong data passed to route"); + ::update(self, *input); + } + + fn attach_navigator(&mut self, messager: Sender) { + ::attach_navigator(self, messager); + } + + fn resize(&mut self, new_size: Rect) { + ::resize(self, new_size); + } + + fn register_command_handler(&mut self, sender: Sender) { + ::register_command_handler(self, sender); + } + + fn handle_command(&mut self, command: Command) -> anyhow::Result<()> { + ::handle_command(self, command) + } + + fn tick(&mut self) -> anyhow::Result<()> { + ::tick(self) + } + + fn handle_key_event(&mut self, key_event: KeyEvent) -> anyhow::Result> { + ::handle_key_event(self, key_event) + } +} + +#[derive(Debug)] +pub enum RouterMessage { + AddRoute(Key, Box>), + AddDialog(Key, Box>), + DelDialog(Key), + Navigate(Navigate), +} + +#[derive(Debug)] +pub struct Router { + routes: HashMap>>, + dialogs: HashMap>>, + dialog_stack: Vec, + active_route: Key, + history: Vec, + command_sender: Sender, + message_receiver: Receiver, + message_sender: Sender, + too_small: TerminalTooSmall, +} + +impl Router { + pub fn new(command_sender: Sender, colors: HacColors) -> Self { + let (message_sender, message_receiver) = std::sync::mpsc::channel(); + Self { + routes: Default::default(), + dialogs: Default::default(), + dialog_stack: Default::default(), + active_route: Default::default(), + history: vec![Default::default()], + message_receiver, + message_sender, + command_sender, + too_small: TerminalTooSmall::new(colors.clone()), + } + } + + pub fn add_route(&mut self, key: Key, mut route: Box>) { + route.attach_navigator(self.message_sender.clone()); + route.register_command_handler(self.command_sender.clone()); + self.routes.insert(key, route); + } + + pub fn add_dialog(&mut self, key: Key, mut dialog: Box>) { + dialog.attach_navigator(self.message_sender.clone()); + dialog.register_command_handler(self.command_sender.clone()); + self.dialogs.insert(key, dialog); + } + + pub fn handle_command(&mut self, command: Command) -> anyhow::Result<()> { + let route = self.get_active_route(); + route.handle_command(command)?; + Ok(()) + } + + fn get_active_route(&mut self) -> &mut Box> { + self.routes + .get_mut(&self.active_route) + .expect("active route doesn't exist on router") + } + + fn get_active_dialog(&mut self) -> Option<&mut Box>> { + let key = self.dialog_stack.last()?; + Some( + self.dialogs + .get_mut(key) + .expect("tried to show a dialog that is not registered"), + ) + } + + fn navigate(&mut self, navigation: Navigate) { + match navigation { + Navigate::To(route) => { + tracing::trace!("navigating to route with key: {route}"); + let curr_route = match self.get_active_dialog() { + Some(dialog) => dialog, + None => self.get_active_route(), + }; + let data = curr_route.data(route); + if self.routes.contains_key(&route) { + self.dialog_stack.clear(); + self.active_route = route; + self.history.push(route); + let route = self.get_active_route(); + route.update(data) + } else if self.dialogs.contains_key(&route) { + self.dialog_stack.push(route); + self.history.push(route); + let dialog = self + .get_active_dialog() + .expect("attempt to navigate to non registered dialog"); + dialog.update(data); + } else { + panic!("tried to navigate to an unknown route"); + } + } + Navigate::Back => { + if self.history.len() <= 1 { + return; + } + + // SAFETY: we just checked if these exists + let curr = self.history.pop().unwrap(); + let prev = *self.history.last().unwrap(); + + // there are only 3 valid scenarios here. + // 1. we are currently on a route, navigating to a previous route; + // 2. we are currently on a dialog, navigating to a previous route; + // 3. we are currently on a dialog, navigating to a previous dialog. + // + // navigating from a route back to a dialog is invalid as we always close + // dialogs when navigating to routes. + + let curr_is_dialog = self.dialogs.contains_key(&curr); + let prev_is_dialog = self.dialogs.contains_key(&prev); + + match (curr_is_dialog, prev_is_dialog) { + // we are on a route, navigating to a route + (false, false) => { + let curr_route = self.get_active_route(); + let data = curr_route.data(prev); + self.active_route = prev; + let new_route = self.get_active_route(); + new_route.update(data); + } + // we are on a dialog, navigating to a route + (true, false) => { + let dialog = self + .get_active_dialog() + .expect("invalid navigation from dialog without displaying a dialog"); + let data = dialog.data(prev); + self.dialog_stack.pop(); + self.active_route = prev; + let new_route = self.get_active_route(); + new_route.update(data); + } + // we are on a dialog, navigating to a dialog + (true, true) => { + let curr_dialog = self + .get_active_dialog() + .expect("invalid navigation from dialog without displaying a dialog"); + let data = curr_dialog.data(prev); + self.dialog_stack.pop(); + let new_dialog = self + .get_active_dialog() + .expect("invalid navigation from dialog to dialog without a previous dialog"); + new_dialog.update(data); + } + (false, true) => panic!("invalid navigation from route to dialog"), + } + } + } + } +} + +impl Renderable for Router { + type Input = (); + type Output = (); + + fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()> { + if frame.size().height < MIN_HEIGHT || frame.size().width < MIN_WIDTH { + Renderable::draw(&mut self.too_small, frame, size)?; + return Ok(()); + } + + match self.message_receiver.try_recv() { + Ok(RouterMessage::AddRoute(key, route)) => self.add_route(key, route), + Ok(RouterMessage::AddDialog(key, route)) => self.add_dialog(key, route), + Ok(RouterMessage::DelDialog(key)) => { + tracing::trace!("dropping dialog with key: {key}"); + self.dialog_stack.retain(|&k| k != key); + self.history.retain(|&k| k != key); + self.dialogs.remove(&key); + } + Ok(RouterMessage::Navigate(navigation)) => self.navigate(navigation), + Err(_) => {} + }; + + let route = self.get_active_route(); + route.draw(frame, size)?; + + if let Some(dialog) = self.get_active_dialog() { + dialog.draw(frame, size)?; + } + + Ok(()) + } + + fn tick(&mut self) -> anyhow::Result<()> { + let route = self.get_active_route(); + route.tick()?; + + if let Some(dialog) = self.get_active_dialog() { + dialog.tick()?; + } + + Ok(()) + } + + fn resize(&mut self, new_size: Rect) { + let route = self.get_active_route(); + route.resize(new_size); + + if let Some(dialog) = self.get_active_dialog() { + dialog.resize(new_size); + } + } + + fn data(&self, _requester: u8) -> Self::Input {} +} + +impl Eventful for Router { + type Result = Command; + + fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) -> anyhow::Result> { + if let Some(dialog) = self.get_active_dialog() { + return dialog.handle_key_event(key_event); + } + + let route = self.get_active_route(); + route.handle_key_event(key_event) + } +} diff --git a/hac-client/src/screen_manager.rs b/hac-client/src/screen_manager.rs deleted file mode 100755 index 6a593fc..0000000 --- a/hac-client/src/screen_manager.rs +++ /dev/null @@ -1,336 +0,0 @@ -use hac_core::{collection::Collection, command::Command}; - -use crate::event_pool::Event; -use crate::pages::collection_dashboard::CollectionDashboard; -use crate::pages::collection_viewer::collection_store::CollectionStore; -use crate::pages::collection_viewer::CollectionViewer; -use crate::pages::terminal_too_small::TerminalTooSmall; -use crate::pages::{Eventful, Renderable}; - -use std::{cell::RefCell, rc::Rc}; - -use ratatui::{layout::Rect, Frame}; -use tokio::sync::mpsc::UnboundedSender; - -#[derive(Debug, Clone, PartialEq)] -pub enum Screens { - CollectionDashboard, - CollectionViewer, - TerminalTooSmall, -} - -/// ScreenManager is responsible for redirecting the user to the screen it should -/// be seeing at any point by the application, it is the entity behind navigation -pub struct ScreenManager<'sm> { - terminal_too_small: TerminalTooSmall<'sm>, - collection_list: CollectionDashboard<'sm>, - /// CollectionViewer is a option as we need a selected collection in order to build - /// all the components inside - collection_viewer: Option>, - - curr_screen: Screens, - /// we keep track of the previous screen, as when the terminal_too_small screen - /// is shown, we know where to redirect the user back - prev_screen: Screens, - - size: Rect, - colors: &'sm hac_colors::Colors, - config: &'sm hac_config::Config, - dry_run: bool, - - collection_store: Rc>, - - // we hold a copy of the sender so we can pass it to the editor when we first - // build one - sender: Option>, -} - -impl<'sm> ScreenManager<'sm> { - pub fn new( - size: Rect, - colors: &'sm hac_colors::Colors, - collections: Vec, - config: &'sm hac_config::Config, - dry_run: bool, - ) -> anyhow::Result { - Ok(Self { - curr_screen: Screens::CollectionDashboard, - prev_screen: Screens::CollectionDashboard, - collection_viewer: None, - terminal_too_small: TerminalTooSmall::new(colors), - collection_list: CollectionDashboard::new(size, colors, collections, dry_run)?, - collection_store: Rc::new(RefCell::new(CollectionStore::default())), - size, - colors, - config, - sender: None, - dry_run, - }) - } - - fn restore_screen(&mut self) { - std::mem::swap(&mut self.curr_screen, &mut self.prev_screen); - } - - fn switch_screen(&mut self, screen: Screens) { - if self.curr_screen == screen { - return; - } - std::mem::swap(&mut self.curr_screen, &mut self.prev_screen); - self.curr_screen = screen; - } - - // events can generate commands, which are sent back to the top level event loop through this - // channel, and goes back down the chain of components as many components may be interested - // in such command - pub fn handle_command(&mut self, command: Command) { - match command { - Command::SelectCollection(collection) | Command::CreateCollection(collection) => { - tracing::debug!("changing to api explorer: {}", collection.info.name); - self.switch_screen(Screens::CollectionViewer); - self.collection_store.borrow_mut().set_state(collection); - self.collection_viewer = Some(CollectionViewer::new( - self.size, - self.collection_store.clone(), - self.colors, - self.config, - self.dry_run, - )); - self.collection_viewer.as_mut().unwrap() - .register_command_handler( - self.sender - .as_ref() - .expect("attempted to register the sender on collection_viewer but it was None") - .clone(), - ) - .ok(); - } - Command::Error(msg) => { - self.collection_list.display_error(msg); - } - _ => {} - } - } -} - -impl Renderable for ScreenManager<'_> { - fn draw(&mut self, frame: &mut Frame, size: Rect) -> anyhow::Result<()> { - match (size.width < 80, size.height < 22) { - (true, _) => self.switch_screen(Screens::TerminalTooSmall), - (_, true) => self.switch_screen(Screens::TerminalTooSmall), - (false, false) if self.curr_screen.eq(&Screens::TerminalTooSmall) => { - self.restore_screen() - } - _ => {} - } - - match &self.curr_screen { - Screens::CollectionViewer => self - .collection_viewer - .as_mut() - .expect( - "should never be able to switch to editor screen without having a collection", - ) - .draw(frame, frame.size())?, - Screens::CollectionDashboard => self.collection_list.draw(frame, frame.size())?, - Screens::TerminalTooSmall => self.terminal_too_small.draw(frame, frame.size())?, - }; - - Ok(()) - } - - fn register_command_handler(&mut self, sender: UnboundedSender) -> anyhow::Result<()> { - self.sender = Some(sender.clone()); - self.collection_list - .register_command_handler(sender.clone())?; - Ok(()) - } - - fn resize(&mut self, new_size: Rect) { - self.size = new_size; - self.collection_list.resize(new_size); - - if let Some(e) = self.collection_viewer.as_mut() { - e.resize(new_size) - } - } - - fn handle_tick(&mut self) -> anyhow::Result<()> { - // currently, only the editor cares about the ticks, used to determine - // when to sync changes in disk - if let Screens::CollectionViewer = &self.curr_screen { - self.collection_viewer - .as_mut() - .expect("we are displaying the editor without having one") - .handle_tick()? - }; - - Ok(()) - } -} - -impl Eventful for ScreenManager<'_> { - type Result = Command; - - fn handle_event(&mut self, event: Option) -> anyhow::Result> { - match self.curr_screen { - Screens::CollectionViewer => self - .collection_viewer - .as_mut() - .expect( - "should never be able to switch to editor screen without having a collection", - ) - .handle_event(event), - Screens::CollectionDashboard => self.collection_list.handle_event(event), - Screens::TerminalTooSmall => Ok(None), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - use hac_core::collection::{self, types::*}; - use ratatui::{backend::TestBackend, Terminal}; - use std::{ - fs::{create_dir, File}, - io::Write, - }; - use tempfile::{tempdir, TempDir}; - - fn setup_temp_collections(amount: usize) -> (TempDir, String) { - let tmp_data_dir = tempdir().expect("Failed to create temp data dir"); - - let tmp_dir = tmp_data_dir.path().join("collections"); - create_dir(&tmp_dir).expect("Failed to create collections directory"); - - for i in 0..amount { - let file_path = tmp_dir.join(format!("test_collection_{}.json", i)); - let mut tmp_file = File::create(&file_path).expect("Failed to create file"); - - write!( - tmp_file, - r#"{{"info": {{ "name": "test_collection_{}", "description": "test_description_{}" }}}}"#, - i, i - ).expect("Failed to write to file"); - - tmp_file.flush().expect("Failed to flush file"); - } - - (tmp_data_dir, tmp_dir.to_string_lossy().to_string()) - } - - #[test] - fn test_show_terminal_too_small_screen() { - let small_in_width = Rect::new(0, 0, 79, 22); - let small_in_height = Rect::new(0, 0, 100, 19); - let colors = hac_colors::Colors::default(); - let (_guard, path) = setup_temp_collections(10); - let collections = collection::collection::get_collections(path).unwrap(); - let config = hac_config::load_config(); - let mut sm = - ScreenManager::new(small_in_width, &colors, collections, &config, false).unwrap(); - let mut terminal = Terminal::new(TestBackend::new(80, 22)).unwrap(); - - sm.draw(&mut terminal.get_frame(), small_in_width).unwrap(); - assert_eq!(sm.curr_screen, Screens::TerminalTooSmall); - - sm.draw(&mut terminal.get_frame(), small_in_height).unwrap(); - assert_eq!(sm.curr_screen, Screens::TerminalTooSmall); - } - - #[test] - fn test_restore_screeen() { - let small = Rect::new(0, 0, 79, 22); - let enough = Rect::new(0, 0, 80, 22); - let colors = hac_colors::Colors::default(); - let (_guard, path) = setup_temp_collections(10); - let collections = collection::collection::get_collections(path).unwrap(); - let config = hac_config::load_config(); - let mut sm = ScreenManager::new(small, &colors, collections, &config, false).unwrap(); - let mut terminal = Terminal::new(TestBackend::new(80, 22)).unwrap(); - - terminal.resize(small).unwrap(); - sm.draw(&mut terminal.get_frame(), small).unwrap(); - assert_eq!(sm.curr_screen, Screens::TerminalTooSmall); - assert_eq!(sm.prev_screen, Screens::CollectionDashboard); - - terminal.resize(enough).unwrap(); - sm.draw(&mut terminal.get_frame(), enough).unwrap(); - assert_eq!(sm.curr_screen, Screens::CollectionDashboard); - assert_eq!(sm.prev_screen, Screens::TerminalTooSmall); - } - - #[test] - fn test_resizing() { - let initial = Rect::new(0, 0, 80, 22); - let expected = Rect::new(0, 0, 100, 22); - let colors = hac_colors::Colors::default(); - let (_guard, path) = setup_temp_collections(10); - let collection = collection::collection::get_collections(path).unwrap(); - let config = hac_config::load_config(); - let mut sm = ScreenManager::new(initial, &colors, collection, &config, false).unwrap(); - - sm.resize(expected); - - assert_eq!(sm.size, expected); - } - - #[test] - fn test_switch_to_explorer_on_select() { - let initial = Rect::new(0, 0, 80, 22); - let colors = hac_colors::Colors::default(); - let collection = Collection { - info: Info { - name: String::from("any_name"), - description: None, - }, - path: "any_path".into(), - requests: None, - }; - let command = Command::SelectCollection(collection.clone()); - let (_guard, path) = setup_temp_collections(10); - let collection = collection::collection::get_collections(path).unwrap(); - let config = hac_config::load_config(); - let (tx, _) = tokio::sync::mpsc::unbounded_channel::(); - let mut sm = ScreenManager::new(initial, &colors, collection, &config, false).unwrap(); - _ = sm.register_command_handler(tx.clone()); - assert_eq!(sm.curr_screen, Screens::CollectionDashboard); - - sm.handle_command(command); - assert_eq!(sm.curr_screen, Screens::CollectionViewer); - } - - #[test] - fn test_register_command_sender_for_dashboard() { - let initial = Rect::new(0, 0, 80, 22); - let colors = hac_colors::Colors::default(); - let (_guard, path) = setup_temp_collections(10); - let collections = collection::collection::get_collections(path).unwrap(); - let config = hac_config::load_config(); - let mut sm = ScreenManager::new(initial, &colors, collections, &config, false).unwrap(); - - let (tx, _) = tokio::sync::mpsc::unbounded_channel::(); - - sm.register_command_handler(tx.clone()).unwrap(); - - assert!(sm.collection_list.command_sender.is_some()); - } - - #[test] - fn test_quit_event() { - let initial = Rect::new(0, 0, 80, 22); - let colors = hac_colors::Colors::default(); - let (_guard, path) = setup_temp_collections(10); - let collections = collection::collection::get_collections(path).unwrap(); - let config = hac_config::load_config(); - let mut sm = ScreenManager::new(initial, &colors, collections, &config, false).unwrap(); - - let event = Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); - - let command = sm.handle_event(Some(event)).unwrap(); - - assert!(command.is_some()); - } -} diff --git a/hac-client/src/utils.rs b/hac-client/src/utils.rs index 6415ef6..cdad85c 100755 --- a/hac-client/src/utils.rs +++ b/hac-client/src/utils.rs @@ -1,7 +1,6 @@ -use hac_core::syntax::highlighter::HIGHLIGHTER; - use std::ops::Add; +use hac_core::syntax::highlighter::HIGHLIGHTER; use ratatui::style::{Color, Stylize}; use ratatui::text::{Line, Span}; use tree_sitter::Tree; @@ -21,10 +20,7 @@ pub fn build_syntax_highlighted_lines( // `HIGHLIGHTER` returns a vector of `ColorInfo`, which contains information about // which kind of token that is, and the style to apply to it - let mut highlights = HIGHLIGHTER - .read() - .unwrap() - .apply(content, tree, &colors.tokens); + let mut highlights = HIGHLIGHTER.read().unwrap().apply(content, tree, &colors.tokens); // these are helper variables to collect each line into styled spans based on the // token it contains @@ -155,8 +151,8 @@ pub fn build_syntax_highlighted_lines( /// eg: /// /// ```rust -/// use ratatui::prelude::Color; /// use hac_client::utils::blend_colors_multiply; +/// use ratatui::prelude::Color; /// /// blend_colors_multiply(Color::Rgb(255, 255, 25), Color::Rgb(0, 0, 0), 0.5); /// ``` @@ -167,7 +163,7 @@ pub fn build_syntax_highlighted_lines( /// use ratatui::prelude::Color; /// Color::Rgb(128, 128, 128); /// ``` -pub fn blend_colors_multiply(original: Color, overlay: Color, alpha: f32) -> Color { +pub fn alpha_blend_multiply(original: Color, overlay: Color, alpha: f32) -> Color { let blend_component = |fg, bg| (alpha * fg as f32 + (1.0 - alpha) * bg as f32) as u8; let Some(foreground) = color_to_rgb(original) else { diff --git a/hac-client/tests/collection_dashboard.rs b/hac-client/tests/collection_dashboard.rs deleted file mode 100755 index 464eebf..0000000 --- a/hac-client/tests/collection_dashboard.rs +++ /dev/null @@ -1,457 +0,0 @@ -use hac_core::collection; - -use hac_client::pages::{collection_dashboard::CollectionDashboard, Eventful, Renderable}; - -use std::fs::{create_dir, File}; -use std::io::Write; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use ratatui::{backend::TestBackend, layout::Rect, Frame, Terminal}; -use tempfile::{tempdir, TempDir}; - -fn setup_temp_collections(amount: usize) -> (TempDir, String) { - let tmp_data_dir = tempdir().expect("Failed to create temp data dir"); - - let tmp_dir = tmp_data_dir.path().join("collections"); - create_dir(&tmp_dir).expect("Failed to create collections directory"); - - for i in 0..amount { - let file_path = tmp_dir.join(format!("test_collection_{}.json", i)); - let mut tmp_file = File::create(&file_path).expect("Failed to create file"); - - write!( - tmp_file, - r#"{{"info": {{ "name": "test_collection_{}", "description": "test_description_{}" }}}}"#, - i, i - ).expect("Failed to write to file"); - - tmp_file.flush().expect("Failed to flush file"); - } - - (tmp_data_dir, tmp_dir.to_string_lossy().to_string()) -} - -fn feed_keys(dashboard: &mut CollectionDashboard, events: &[KeyEvent]) { - for event in events { - _ = dashboard.handle_key_event(*event); - } -} - -fn get_rendered_from_buffer(frame: &mut Frame, size: Rect) -> Vec { - frame - .buffer_mut() - .content - .chunks(size.width.into()) - .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) - .collect::>() -} - -#[test] -fn test_draw_empty_message() { - let colors = hac_colors::Colors::default(); - let size = Rect::new(0, 0, 80, 22); - let mut dashboard = CollectionDashboard::new(size, &colors, vec![], false).unwrap(); - let mut terminal = Terminal::new(TestBackend::new(80, 22)).unwrap(); - let mut frame = terminal.get_frame(); - - let expected = [ - " █▖▐▌ ▝█ ▝█ ▟ ▀ ", - " █▜▟▌▟▀▙ ▟▀▙ ▟▀▙ █ █ ▟▀▙ ▟▀▙ ▝█▀ ▝█ ▟▀▙ █▀▙ ▟▀▀ ", - " █ ▜▌█ █ █ ▄ █ █ █ █ █▀▀ █ ▄ █▗ █ █ █ █ █ ▝▀▙ ", - " ▀ ▝▘▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▘ ▝▀▘ ▝▀▘ ▀ ▀ ▀▀▘ ", - ]; - - dashboard.draw(&mut frame, size).unwrap(); - - let rendered = frame - .buffer_mut() - .content - .chunks(size.width.into()) - .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) - .collect::>(); - - let result = [&rendered[10], &rendered[11], &rendered[12], &rendered[13]]; - - assert_eq!(result, expected); -} - -#[test] -fn test_draw_no_matches_message() { - let colors = hac_colors::Colors::default(); - let size = Rect::new(0, 0, 80, 22); - let (_guard, path) = setup_temp_collections(3); - let collections = collection::collection::get_collections(path).unwrap(); - let mut dashboard = CollectionDashboard::new(size, &colors, collections, false).unwrap(); - let mut terminal = Terminal::new(TestBackend::new(80, 22)).unwrap(); - let mut frame = terminal.get_frame(); - - let expected = [ - " █▖▐▌ ▟ ▜▌ ", - " █▜▟▌▟▀▙ █▄█▖▝▀▙ ▝█▀ ▟▀▙ ▐▙▜▖▟▀▙ ▟▀▀ ", - " █ ▜▌█ █ █▜▜▌▟▀█ █▗ █ ▄ ▐▌▐▌█▀▀ ▝▀▙ ", - " ▀ ▝▘▝▀▘ ▀ ▝▘▝▀▝▘ ▝▘ ▝▀▘ ▀▘▝▘▝▀▘ ▀▀▘ ", - ]; - - feed_keys( - &mut dashboard, - &[ - KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - ], - ); - dashboard.draw(&mut frame, size).unwrap(); - - let rendered = frame - .buffer_mut() - .content - .chunks(size.width.into()) - .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) - .collect::>(); - - let result = [&rendered[10], &rendered[11], &rendered[12], &rendered[13]]; - - assert_eq!(result, expected); -} - -#[test] -fn draw_hint_text() { - let colors = hac_colors::Colors::default(); - let size = Rect::new(0, 0, 80, 22); - let (_guard, path) = setup_temp_collections(3); - let collections = collection::collection::get_collections(path).unwrap(); - let mut dashboard = CollectionDashboard::new(size, &colors, collections, false).unwrap(); - let mut terminal = Terminal::new(TestBackend::new(80, 22)).unwrap(); - let mut frame = terminal.get_frame(); - - let expected = - [" [h/j/k/l to move] [n -> new] [enter -> select item] [? -> help] [ -> quit]"]; - - dashboard.draw(&mut frame, size).unwrap(); - let rendered = get_rendered_from_buffer(&mut frame, size); - let result = [rendered.last().unwrap().as_str()]; - - assert_eq!(result, expected); -} - -#[test] -fn draw_filter_prompt() { - let colors = hac_colors::Colors::default(); - let size = Rect::new(0, 0, 80, 22); - let (_guard, path) = setup_temp_collections(3); - let collections = collection::collection::get_collections(path).unwrap(); - let mut dashboard = CollectionDashboard::new(size, &colors, collections, false).unwrap(); - let mut terminal = Terminal::new(TestBackend::new(80, 22)).unwrap(); - let mut frame = terminal.get_frame(); - let expected = - [" /any_filter "]; - - feed_keys( - &mut dashboard, - &[ - KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('_'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), - KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE), - ], - ); - - dashboard.draw(&mut frame, size).unwrap(); - let rendered = get_rendered_from_buffer(&mut frame, size); - let result = [rendered.last().unwrap().as_str()]; - - assert_eq!(result, expected); -} - -#[test] -fn test_draw_title() { - let colors = hac_colors::Colors::default(); - let size = Rect::new(0, 0, 80, 22); - let mut dashboard = CollectionDashboard::new(size, &colors, vec![], false).unwrap(); - let mut terminal = Terminal::new(TestBackend::new(80, 22)).unwrap(); - let mut frame = terminal.get_frame(); - - let expected = [ - " ▟▀▙ ▝█ ▟ ▝█ ▝█ ▟ ▀ ", - " ▜▙ ▟▀▙ █ ▟▀▙ ▟▀▙ ▝█▀ ▝▀▙ ▟▀▙ ▟▀▙ █ █ ▟▀▙ ▟▀▙ ▝█▀ ▝█ ▟▀▙ █▀▙ ", - " ▄▝█ █▀▀ █ █▀▀ █ ▄ █▗ ▟▀█ █ ▄ █ █ █ █ █▀▀ █ ▄ █▗ █ █ █ █ █ ", - " ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▘ ▝▀▝▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▘ ▝▀▘ ▝▀▘ ▀ ▀ ", - ]; - - dashboard.draw(&mut frame, size).unwrap(); - - let rendered = frame - .buffer_mut() - .content - .chunks(size.width.into()) - .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) - .collect::>(); - - let result = [&rendered[1], &rendered[2], &rendered[3], &rendered[4]]; - - assert_eq!(result, expected); -} - -#[test] -fn test_draw_error() { - let colors = hac_colors::Colors::default(); - let size = Rect::new(0, 0, 80, 22); - let mut dashboard = CollectionDashboard::new(size, &colors, vec![], false).unwrap(); - let mut terminal = Terminal::new(TestBackend::new(80, 22)).unwrap(); - let mut frame = terminal.get_frame(); - - let expected = [ - " ", - " ▟▀▙ ▝█ ┌─────────────────────────────────────┐ ▟ ▀ ", - " ▜▙ ▟▀▙ █ ▟▀▙ ▟│ │▟▀▙ ▝█▀ ▝█ ▟▀▙ █▀▙ ", - " ▄▝█ █▀▀ █ █▀▀ █│ any_error_message │█ ▄ █▗ █ █ █ █ █ ", - " ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝│ │▝▀▘ ▝▘ ▝▀▘ ▝▀▘ ▀ ▀ ", - " │ │ ", - " │ │ ", - " │ │ ", - " │ │ ", - " │ │ ", - " █▖▐▌ │ │ ", - " █▜▟▌▟▀▙│ │▙ █▀▙ ▟▀▀ ", - " █ ▜▌█ █│ │█ █ █ ▝▀▙ ", - " ▀ ▝▘▝▀▘│ │▘ ▀ ▀ ▀▀▘ ", - " │ │ ", - " │ │ ", - " │ │ ", - " │ │ ", - " │ (O)k │ ", - " │ │ ", - " └─────────────────────────────────────┘ ", - " ", - ]; - - dashboard.display_error("any_error_message".into()); - dashboard.draw(&mut frame, size).unwrap(); - - let rendered = frame - .buffer_mut() - .content - .chunks(size.width.into()) - .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) - .collect::>(); - - assert_eq!(rendered, expected); -} - -#[test] -fn test_draw_help() { - let colors = hac_colors::Colors::default(); - let size = Rect::new(0, 0, 80, 22); - let mut dashboard = CollectionDashboard::new(size, &colors, vec![], false).unwrap(); - let mut terminal = Terminal::new(TestBackend::new(80, 22)).unwrap(); - let mut frame = terminal.get_frame(); - - let expected = [ - " ", - " ▟▀▙ ▝█ ▟ ▝█ ▝█ ▟ ▀ ", - " ▜▙ ▟▀▙ █ ▟▀▙ ▟▀▙ ▝█▀ ▝▀▙ ▟▀▙ ▟▀▙ █ █ ▟▀▙ ▟▀▙ ▝█▀ ▝█ ▟▀▙ █▀▙ ", - " ▄▝█ █▀▀ █ █▀▀ █ ▄ █▗ ▟▀█ █ ▄ █ █ █ █ █▀▀ █ ▄ █▗ █ █ █ █ █ ", - " ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▘ ▝▀▝▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▘ ▝▀▘ ▝▀▘ ▀ ▀ ", - " h/ - select left item ", - " j/ - select item below ", - " k/ - select item above ", - " l/ - select right item ", - " n/c - creates a new collection ", - " █▖▐▌d - deletes the selected collection ", - " █▜▟▌? - toggle this help window█ ▟▀▙ █▀▙ ▟▀▀ ", - " █ ▜▌enter - select item under cursor █ █ █ █ ▝▀▙ ", - " ▀ ▝▘/ - enter filter mode ▝▘ ▝▀▘ ▝▀▘ ▀ ▀ ▀▀▘ ", - " - quits the application ", - " ", - " press any key to go back ", - " ", - " ", - " ", - " ", - " ", - ]; - - feed_keys( - &mut dashboard, - &[KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)], - ); - dashboard.draw(&mut frame, size).unwrap(); - - let rendered = frame - .buffer_mut() - .content - .chunks(size.width.into()) - .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) - .collect::>(); - - assert_eq!(rendered, expected); -} - -#[test] -fn test_draw_form_popup() { - let colors = hac_colors::Colors::default(); - let size = Rect::new(0, 0, 80, 22); - let mut dashboard = CollectionDashboard::new(size, &colors, vec![], false).unwrap(); - let mut terminal = Terminal::new(TestBackend::new(80, 22)).unwrap(); - let mut frame = terminal.get_frame(); - - let expected = [ - "新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 ┌Name─────────────────────────────┐ 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 │My awesome API │ 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 └─────────────────────────────────┘ 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 ┌Description──────────────────────┐ 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 │Request testing │ 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 └─────────────────────────────────┘ 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 ╭────────╮ ╭────────╮ 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 │ Create │ │ Cancel │ 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 ╰────────╯ ╰────────╯ 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 [Tab] to switch focus [Enter] to se 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 ", - "新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 新 ", - ]; - - feed_keys( - &mut dashboard, - &[KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)], - ); - dashboard.draw(&mut frame, size).unwrap(); - - let rendered = frame - .buffer_mut() - .content - .chunks(size.width.into()) - .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) - .collect::>(); - - println!("{:#?}", frame.buffer_mut().content()); - - assert_eq!(rendered, expected); -} - -#[test] -fn test_draw_delete_prompt() { - let colors = hac_colors::Colors::default(); - let size = Rect::new(0, 0, 80, 22); - let (_guard, path) = setup_temp_collections(3); - let collections = collection::collection::get_collections(path).unwrap(); - let mut dashboard = CollectionDashboard::new(size, &colors, collections, false).unwrap(); - let mut terminal = Terminal::new(TestBackend::new(80, 22)).unwrap(); - let mut frame = terminal.get_frame(); - - let expected = [ - " ", - " ▟▀▙ ▝█ ▟ ▝█ ▝█ ▟ ▀ ", - " ▜▙ ▟▀▙ █ ▟▀▙ ▟▀▙ ▝█▀ ▝▀▙ ▟▀▙ ▟▀▙ █ █ ▟▀▙ ▟▀▙ ▝█▀ ▝█ ▟▀▙ █▀▙ ", - " ▄▝█ █▀▀ █ █▀▀ █ ▄ █▗ ▟▀█ █ ▄ █ █ █ █ █▀▀ █ ▄ █▗ █ █ █ █ █ ", - " ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▘ ▝▀▝▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▘ ▝▀▘ ▝▀▘ ▀ ▀ ", - " ", - " ╭────────────────────────────────────╮╭────────────────────────────────────╮ ↑ ", - " │test_collection_0┌─────────────────────────────────────┐ │ █ ", - " │test_description_│ │ │ █ ", - " ╰─────────────────│ You really want to delete │──────────────────╯ █ ", - " ╭─────────────────│ collection test_collection_0? │ █ ", - " │test_collection_2│ │ █ ", - " │test_description_│ (y)es (n)o │ █ ", - " ╰─────────────────│ │ █ ", - " └─────────────────────────────────────┘ █ ", - " █ ", - " █ ", - " █ ", - " █ ", - " █ ", - " ↓ ", - " ", - ]; - - feed_keys( - &mut dashboard, - &[KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)], - ); - dashboard.draw(&mut frame, size).unwrap(); - - let rendered = frame - .buffer_mut() - .content - .chunks(size.width.into()) - .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) - .collect::>(); - - println!("{:?}", frame.buffer_mut()); - - assert_eq!(rendered, expected); -} - -#[test] -fn test_draw_collections_list() { - let colors = hac_colors::Colors::default(); - let size = Rect::new(0, 0, 80, 22); - let (_guard, path) = setup_temp_collections(3); - let collections = collection::collection::get_collections(path).unwrap(); - let mut dashboard = CollectionDashboard::new(size, &colors, collections, false).unwrap(); - let mut terminal = Terminal::new(TestBackend::new(80, 22)).unwrap(); - let mut frame = terminal.get_frame(); - - let expected = [ - " ", - " ▟▀▙ ▝█ ▟ ▝█ ▝█ ▟ ▀ ", - " ▜▙ ▟▀▙ █ ▟▀▙ ▟▀▙ ▝█▀ ▝▀▙ ▟▀▙ ▟▀▙ █ █ ▟▀▙ ▟▀▙ ▝█▀ ▝█ ▟▀▙ █▀▙ ", - " ▄▝█ █▀▀ █ █▀▀ █ ▄ █▗ ▟▀█ █ ▄ █ █ █ █ █▀▀ █ ▄ █▗ █ █ █ █ █ ", - " ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▘ ▝▀▝▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▀▘ ▝▘ ▝▀▘ ▝▀▘ ▀ ▀ ", - " ", - " ╭────────────────────────────────────╮╭────────────────────────────────────╮ ↑ ", - " │test_collection_0 ││test_collection_1 │ █ ", - " │test_description_0 ││test_description_1 │ █ ", - " ╰────────────────────────────────────╯╰────────────────────────────────────╯ █ ", - " ╭────────────────────────────────────╮ █ ", - " │test_collection_2 │ █ ", - " │test_description_2 │ █ ", - " ╰────────────────────────────────────╯ █ ", - " █ ", - " █ ", - " █ ", - " █ ", - " █ ", - " █ ", - " ↓ ", - " [h/j/k/l to move] [n -> new] [enter -> select item] [? -> help] [ -> quit]", - ]; - - dashboard.draw(&mut frame, size).unwrap(); - - let rendered = frame - .buffer_mut() - .content - .chunks(size.width.into()) - .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) - .collect::>(); - - assert_eq!(rendered, expected); -} diff --git a/hac-colors/src/colors.rs b/hac-colors/src/colors.rs index ee3a2a5..17cca83 100755 --- a/hac-colors/src/colors.rs +++ b/hac-colors/src/colors.rs @@ -61,14 +61,8 @@ fn token_highlight() -> HashMap { tokens.insert("boolean".into(), Style::new().fg(colors.red)); tokens.insert("number".into(), Style::new().fg(colors.magenta)); tokens.insert("property".into(), Style::new().fg(colors.yellow)); - tokens.insert( - "punctuation.bracket".into(), - Style::new().fg(colors.magenta), - ); - tokens.insert( - "punctuation.delimiter".into(), - Style::new().fg(colors.magenta), - ); + tokens.insert("punctuation.bracket".into(), Style::new().fg(colors.magenta)); + tokens.insert("punctuation.delimiter".into(), Style::new().fg(colors.magenta)); tokens.insert("string".into(), Style::new().fg(colors.green)); tokens @@ -78,7 +72,8 @@ impl Default for PrimaryColors { fn default() -> Self { PrimaryColors { foreground: Color::Rgb(0x0F, 0x14, 0x19), - background: Color::Rgb(0x18, 0x16, 0x16), + //background: Color::Rgb(0x18, 0x16, 0x16), + background: Color::Reset, accent: Color::Rgb(0xb6, 0x92, 0x7b), hover: Color::Rgb(0x38, 0x38, 0x38), } diff --git a/hac-config/src/config.rs b/hac-config/src/config.rs index 1afd18d..1021b26 100755 --- a/hac-config/src/config.rs +++ b/hac-config/src/config.rs @@ -1,11 +1,11 @@ -use crate::default_config::DEFAULT_CONFIG; -use crate::{EditorMode, APP_NAME, CONFIG_ENV_VAR, CONFIG_FILE, XDG_DEFAULTS, XDG_ENV_VARS}; - use std::collections::HashMap; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use crate::default_config::DEFAULT_CONFIG; +use crate::{EditorMode, APP_NAME, CONFIG_ENV_VAR, CONFIG_FILE, XDG_DEFAULTS, XDG_ENV_VARS}; + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub enum Action { Undo, @@ -49,9 +49,21 @@ pub enum Action { JumpToEmptyLineAbove, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, Copy, Clone)] +pub enum CollectionExtensions { + #[default] + Json, +} + +#[derive(Debug, Serialize, Deserialize)] pub struct Config { pub editor_keys: Keys, + #[serde(default)] + pub collection_ext: CollectionExtensions, + pub dry_run: bool, + pub tab_size: usize, + pub enable_icons: bool, + pub sync_delay: u64, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/hac-config/src/default_config.rs b/hac-config/src/default_config.rs index 3ab566b..dcb85cc 100755 --- a/hac-config/src/default_config.rs +++ b/hac-config/src/default_config.rs @@ -1,4 +1,9 @@ pub static DEFAULT_CONFIG: &str = r##" +dry_run = false +tab_size = 2 +enable_icons = true +sync_delay = 5 + [editor_keys.normal] "u" = "Undo" "n" = "FindNext" diff --git a/hac-config/src/lib.rs b/hac-config/src/lib.rs index ab305cc..4693c0d 100755 --- a/hac-config/src/lib.rs +++ b/hac-config/src/lib.rs @@ -1,13 +1,9 @@ pub mod config; -pub mod data; mod default_config; pub use config::{ default_as_str, get_config_dir_path, get_usual_path, load_config, Action, Config, KeyAction, }; -pub use data::{ - get_collections_dir, get_or_create_collections_dir, get_or_create_data_dir, log_file, -}; use serde::{Deserialize, Serialize}; #[derive(PartialEq, Deserialize, Serialize, Debug, Clone)] @@ -17,19 +13,20 @@ pub enum EditorMode { } pub static APP_NAME: &str = "hac"; +pub static LOGFILE: &str = "hac.log"; pub static COLLECTIONS_DIR: &str = "collections"; pub static CONFIG_FILE: &str = "hac.toml"; pub static THEMES_DIR: &str = "themes"; pub static CONFIG_ENV_VAR: &str = "HAC_CONFIG"; #[cfg(unix)] -static XDG_ENV_VARS: [&str; 2] = ["XDG_CONFIG_HOME", "XDG_DATA_HOME"]; +pub static XDG_ENV_VARS: [&str; 2] = ["XDG_CONFIG_HOME", "XDG_DATA_HOME"]; #[cfg(windows)] -static XDG_ENV_VARS: [&str; 2] = ["LOCALAPPDATA", "LOCALAPPDATA"]; +pub static XDG_ENV_VARS: [&str; 2] = ["LOCALAPPDATA", "LOCALAPPDATA"]; #[cfg(unix)] -static XDG_DEFAULTS: [&str; 2] = [".config", ".local/share"]; +pub static XDG_DEFAULTS: [&str; 2] = [".config", ".local/share"]; #[cfg(windows)] -static XDG_DEFAULTS: [&str; 2] = ["AppData\\Local", "AppData\\Local"]; +pub static XDG_DEFAULTS: [&str; 2] = ["AppData\\Local", "AppData\\Local"]; diff --git a/hac-core/Cargo.toml b/hac-core/Cargo.toml index 03a393b..9720484 100755 --- a/hac-core/Cargo.toml +++ b/hac-core/Cargo.toml @@ -15,6 +15,7 @@ workspace = "../" [dependencies] hac-config.workspace = true +hac-store.workspace = true anyhow.workspace = true tracing.workspace = true diff --git a/hac-core/src/collection.rs b/hac-core/src/collection.rs deleted file mode 100755 index e06ccc4..0000000 --- a/hac-core/src/collection.rs +++ /dev/null @@ -1,5 +0,0 @@ -#[allow(clippy::module_inception)] -pub mod collection; -pub mod types; -pub use types::Collection; -mod errors; diff --git a/hac-core/src/collection/collection.rs b/hac-core/src/collection/collection.rs deleted file mode 100755 index ea3cb7e..0000000 --- a/hac-core/src/collection/collection.rs +++ /dev/null @@ -1,71 +0,0 @@ -use crate::collection::types::{Collection, Info}; - -use std::path::Path; -use std::time::{self, UNIX_EPOCH}; - -#[tracing::instrument(err)] -pub fn get_collections_from_config() -> anyhow::Result> { - let collections_dir = hac_config::get_or_create_collections_dir(); - get_collections(collections_dir) -} - -#[tracing::instrument(skip(collections_dir), err)] -pub fn get_collections

(collections_dir: P) -> anyhow::Result> -where - P: AsRef, -{ - let items = std::fs::read_dir(&collections_dir)?; - - let mut collections = vec![]; - - for item in items.into_iter().flatten() { - let file_name = item.file_name(); - let collection_name = collections_dir.as_ref().join(file_name); - let file = std::fs::read_to_string(&collection_name)?; - let mut collection: Collection = serde_json::from_str(&file)?; - collection.path = collection_name; - collections.push(collection); - } - - collections.sort_by(|a, b| a.info.name.cmp(&b.info.name)); - - Ok(collections) -} - -pub fn create_from_form(name: String, description: String) -> Collection { - let name = if name.is_empty() { - let now = time::SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis(); - format!("Unnamed Collection {}", now) - } else { - name - }; - - let collections_dir = hac_config::get_collections_dir(); - let name_as_file_name = name.to_lowercase().replace(' ', "_"); - let collection_name = collections_dir.join(name_as_file_name); - - Collection { - info: Info { - name, - description: Some(description), - }, - requests: None, - path: format!("{}.json", collection_name.to_string_lossy()).into(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn test_creating_from_form() { - let collection = create_from_form("any valid name".into(), "any desctiption".into()); - - assert!(collection.path.to_string_lossy().ends_with(".json")); - assert!(collection.info.name.eq("any valid name")); - assert!(collection.info.description.is_some()) - } -} diff --git a/hac-core/src/collection/errors.rs b/hac-core/src/collection/errors.rs deleted file mode 100755 index 8cc1df0..0000000 --- a/hac-core/src/collection/errors.rs +++ /dev/null @@ -1,22 +0,0 @@ -pub enum CollectionError { - Unknown(String), -} - -impl std::fmt::Display for CollectionError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CollectionError::Unknown(msg) => write!(f, "{}", msg), - } - } -} - -impl From for CollectionError -where - E: Into, -{ - fn from(err: E) -> Self { - let err: anyhow::Error = err.into(); - let msg = err.to_string(); - CollectionError::Unknown(msg) - } -} diff --git a/hac-core/src/collection/types.rs b/hac-core/src/collection/types.rs deleted file mode 100755 index 31f65a7..0000000 --- a/hac-core/src/collection/types.rs +++ /dev/null @@ -1,289 +0,0 @@ -use std::hash::Hash; -use std::path::PathBuf; -use std::sync::{Arc, RwLock}; - -use serde::{Deserialize, Serialize}; - -/// a collection is represented as a file on the file system and holds every -/// request and metadata -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct Collection { - /// basic information about the collection such as name and description - pub info: Info, - /// maybe a vector of `RequestKind` that are part of the collection - pub requests: Option>>>, - /// path is a virtual field used only during runtime to know where to - /// sync the file, this will be the absolute path to the file on the - /// users computer - #[serde(skip)] - pub path: PathBuf, -} - -/// we store requests on a collection and on directories as a enum that could -/// be either an request or a directory. This enables us to have nested -/// directories, although we don't support that now and might not ever support. -/// -/// Single means its a request -/// Nested means its a directory -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(untagged)] -pub enum RequestKind { - Single(Arc>), - Nested(Directory), -} - -impl RequestKind { - /// helper method to get the name of either a request or a directory without - /// needing to narrow the type - pub fn get_name(&self) -> String { - match self { - RequestKind::Single(req) => req.read().unwrap().name.to_string(), - RequestKind::Nested(dir) => dir.name.to_string(), - } - } - - /// helper method to know wether a request kind is a directory or not - pub fn is_dir(&self) -> bool { - match self { - RequestKind::Single(_) => false, - RequestKind::Nested(_) => true, - } - } - - /// helper method to get the id of either a request or a directory without - /// needing to narrow the type - pub fn get_id(&self) -> String { - match self { - RequestKind::Single(req) => req.read().unwrap().id.to_string(), - RequestKind::Nested(dir) => dir.id.to_string(), - } - } -} - -/// we store headers as a simple struct which is composed by a pair which -/// represents name/value of a header, and wether it is enabled or not. -/// -/// disabled headers should not be sent on requests -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct HeaderMap { - pub pair: (String, String), - pub enabled: bool, -} - -/// set of methods we currently support on HTTP requests -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone)] -#[serde(rename_all = "UPPERCASE")] -pub enum RequestMethod { - Get, - Post, - Put, - Patch, - Delete, -} - -impl TryFrom for RequestMethod { - type Error = anyhow::Error; - - fn try_from(value: usize) -> anyhow::Result { - match value { - 0 => Ok(RequestMethod::Get), - 1 => Ok(RequestMethod::Post), - 2 => Ok(RequestMethod::Put), - 3 => Ok(RequestMethod::Patch), - 4 => Ok(RequestMethod::Delete), - _ => anyhow::bail!("invalid request method index"), - } - } -} - -/// this is a cyclic implementation of `next` and `prev` that are used -/// by the UI to cycle through elements, usually by using tab -impl RequestMethod { - pub fn next(&self) -> Self { - match self { - RequestMethod::Get => RequestMethod::Post, - RequestMethod::Post => RequestMethod::Put, - RequestMethod::Put => RequestMethod::Patch, - RequestMethod::Patch => RequestMethod::Delete, - RequestMethod::Delete => RequestMethod::Get, - } - } - - pub fn prev(&self) -> Self { - match self { - RequestMethod::Get => RequestMethod::Delete, - RequestMethod::Post => RequestMethod::Get, - RequestMethod::Put => RequestMethod::Post, - RequestMethod::Patch => RequestMethod::Put, - RequestMethod::Delete => RequestMethod::Patch, - } - } -} - -impl std::fmt::Display for RequestMethod { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Get => f.write_str("GET"), - Self::Post => f.write_str("POST"), - Self::Put => f.write_str("PUT"), - Self::Patch => f.write_str("PATCH"), - Self::Delete => f.write_str("DELETE"), - } - } -} - -// custom iterator implementation for RequestMethod to be able to map over -// its variants without writing a lot of boilerplate everytime -// -// NOTE: if this kind of behavior repeats a lot we might want to introduce -// a helper crate like strum -impl RequestMethod { - pub fn iter() -> std::slice::Iter<'static, RequestMethod> { - [ - RequestMethod::Get, - RequestMethod::Post, - RequestMethod::Put, - RequestMethod::Patch, - RequestMethod::Delete, - ] - .iter() - } -} - -/// This is how we store a request on the system, basically this stores all -/// needed information about a request to be able to perform any actions we -/// allow. -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct Request { - /// we store an uuid on each request to be able to easily identify them - /// as identifying by name is - pub id: String, - /// each request has to have a method, this is the HTTP method that will - /// be used when realizing requests - pub method: RequestMethod, - /// name of the request that will be displayed on the sidebar - pub name: String, - /// uri that the request will be sent against - pub uri: String, - /// all headers used on given request, sometimes, we may include additional - /// headers if required to make a request - pub headers: Option>, - /// auth method used by the request, eg: Bearer or basic auth - pub auth_method: Option, - /// if this request lives as a children of a directory, the uuid of given - /// directory will be stored here, this is mainly used to know where to - /// insert or move the request - pub parent: Option, - /// body of the request, this will only be sent in methods that accept a - /// body, like POST or PUT, for example - pub body: Option, - #[serde(rename = "bodyType")] - /// the type of the body to be used, like `application/json` or any other - /// accepted body type - pub body_type: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum AuthMethod { - Bearer, - None, - A, - B, - C, - D, - E, -} - -#[derive(Default)] -pub struct AuthKindIter { - inner: u8, -} - -impl std::fmt::Display for AuthMethod { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - AuthMethod::None => write!(f, "None"), - AuthMethod::Bearer => write!(f, "Bearer"), - AuthMethod::A => write!(f, "Bearer"), - AuthMethod::B => write!(f, "Bearer"), - AuthMethod::C => write!(f, "Bearer"), - AuthMethod::D => write!(f, "Bearer"), - AuthMethod::E => write!(f, "Bearer"), - } - } -} - -impl From for AuthMethod { - fn from(value: usize) -> Self { - match value { - 0 => AuthMethod::None, - 1 => AuthMethod::Bearer, - 2 => AuthMethod::A, - 3 => AuthMethod::B, - 4 => AuthMethod::C, - 5 => AuthMethod::D, - 6 => AuthMethod::E, - _ => AuthMethod::None, - } - } -} - -impl AuthMethod { - pub fn iter() -> AuthKindIter { - AuthKindIter::default() - } - - pub fn len() -> usize { - AuthKindIter::default().count() - } -} - -impl Iterator for AuthKindIter { - type Item = AuthMethod; - - fn next(&mut self) -> Option { - let variant = match self.inner { - 0 => Some(AuthMethod::None), - 1 => Some(AuthMethod::Bearer), - 2 => Some(AuthMethod::A), - 3 => Some(AuthMethod::B), - 4 => Some(AuthMethod::C), - 5 => Some(AuthMethod::D), - 6 => Some(AuthMethod::E), - _ => None, - }; - self.inner += 1; - variant - } -} - -/// a collection of all available body types we support. -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] -pub enum BodyType { - #[serde(rename = "json")] - Json, -} - -/// a directory can hold a vector of requests, which will be -/// displayed as a tree-like view in the sidebar -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub struct Directory { - /// as it is the same with requests, directories have an id to make - /// easier to know relations without having to rely on references - /// and lifetimes - pub id: String, - /// name of the directory that will be used in the display on the - /// sidebar - pub name: String, - /// vector of requests that are children of this directory - pub requests: Arc>>, -} - -/// basic information about a colleciton -#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] -pub struct Info { - /// name of the collection that will be displayed onscreen - pub name: String, - /// a optional description in case it is useful - pub description: Option, -} diff --git a/hac-core/src/command.rs b/hac-core/src/command.rs index 0737f88..8d28a47 100755 --- a/hac-core/src/command.rs +++ b/hac-core/src/command.rs @@ -1,4 +1,4 @@ -use crate::collection::Collection; +use hac_store::collection::Collection; #[derive(Debug)] pub enum Command { diff --git a/hac-core/src/fs.rs b/hac-core/src/fs.rs deleted file mode 100755 index cfcbd8b..0000000 --- a/hac-core/src/fs.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod error; -#[allow(clippy::module_inception)] -mod fs; - -pub use fs::*; diff --git a/hac-core/src/fs/error.rs b/hac-core/src/fs/error.rs deleted file mode 100755 index 9c03588..0000000 --- a/hac-core/src/fs/error.rs +++ /dev/null @@ -1,20 +0,0 @@ -#[derive(Debug)] -pub enum FsError { - SerializationError(String), - IOError(String), - CollectionAlreadyExists(String), - Unknown, -} - -impl std::fmt::Display for FsError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - FsError::CollectionAlreadyExists(path) => { - write!(f, "collection {:?} already exists", path) - } - FsError::Unknown => write!(f, "unknown error"), - FsError::SerializationError(msg) => write!(f, "{}", msg), - FsError::IOError(msg) => write!(f, "{}", msg), - } - } -} diff --git a/hac-core/src/fs/fs.rs b/hac-core/src/fs/fs.rs deleted file mode 100755 index 4f1f9db..0000000 --- a/hac-core/src/fs/fs.rs +++ /dev/null @@ -1,64 +0,0 @@ -use crate::collection::{collection::create_from_form, Collection}; -use crate::fs::error::FsError; - -use std::path::Path; - -#[tracing::instrument(err, skip_all)] -pub async fn delete_collection

(path: P) -> anyhow::Result<(), FsError> -where - P: AsRef, -{ - let path = path.as_ref(); - tokio::fs::remove_file(path) - .await - .map_err(|_| FsError::IOError(format!("failed to delete collection: {:?}", path)))?; - - tracing::debug!("sucessfully deleted collection: {:?}", path); - Ok(()) -} - -#[tracing::instrument(err)] -pub async fn create_collection( - name: String, - description: String, - dry_run: bool, -) -> anyhow::Result { - let collection = create_from_form(name, description); - - if collection.path.exists() { - return Err(FsError::CollectionAlreadyExists( - collection.path.to_string_lossy().to_string(), - )); - } - - let serialized_collection = serde_json::to_string(&collection) - .map_err(|e| FsError::SerializationError(e.to_string()))?; - - // if we are on a dry_run, we skip syncing - if !dry_run { - tokio::fs::write(&collection.path, serialized_collection) - .await - .map_err(|e| FsError::IOError(format!("failed to write collection: {:?}", e)))?; - } - - tracing::debug!("successfully created new collection: {:?}", collection.path); - Ok(collection) -} - -pub async fn sync_collection(collection: Collection) -> anyhow::Result<(), FsError> { - let collection_str = serde_json::to_string(&collection) - .map_err(|e| FsError::SerializationError(e.to_string()))?; - - tokio::fs::write(&collection.path, collection_str) - .await - .map_err(|_| { - FsError::IOError(format!( - "failed to synchronize collection {:?}", - collection.path - )) - })?; - - tracing::debug!("synchronization of collection: {:?}", collection.path); - - Ok(()) -} diff --git a/hac-core/src/lib.rs b/hac-core/src/lib.rs index 7acc170..2f6406f 100755 --- a/hac-core/src/lib.rs +++ b/hac-core/src/lib.rs @@ -1,6 +1,4 @@ -pub mod collection; pub mod command; -pub mod fs; pub mod net; pub mod syntax; pub mod text_object; diff --git a/hac-core/src/net.rs b/hac-core/src/net/mod.rs old mode 100755 new mode 100644 similarity index 100% rename from hac-core/src/net.rs rename to hac-core/src/net/mod.rs diff --git a/hac-core/src/net/request_client.rs b/hac-core/src/net/request_client.rs index 4401408..a6e1e5c 100644 --- a/hac-core/src/net/request_client.rs +++ b/hac-core/src/net/request_client.rs @@ -1,4 +1,4 @@ -use crate::collection::types::Request; +use hac_store::collection::Request; #[derive(Debug)] pub struct RequestClient { @@ -42,12 +42,10 @@ impl RequestClient { request: &Request, mut request_builder: reqwest::RequestBuilder, ) -> reqwest::RequestBuilder { - if let Some(ref headers) = request.headers { - for header in headers.iter().filter(|header| header.enabled) { - let header_name = header.pair.0.clone(); - let header_value = header.pair.1.clone(); - request_builder = request_builder.header(header_name, header_value); - } + for header in request.headers.iter().filter(|header| header.enabled) { + let header_name = header.key.clone(); + let header_value = header.val.clone(); + request_builder = request_builder.header(header_name, header_value); } request_builder diff --git a/hac-core/src/net/request_manager.rs b/hac-core/src/net/request_manager.rs index faad89f..f7386d6 100755 --- a/hac-core/src/net/request_manager.rs +++ b/hac-core/src/net/request_manager.rs @@ -1,13 +1,14 @@ -use crate::collection::types::{BodyType, Request}; -use crate::net::request_strategies::{http_strategy::HttpResponse, RequestStrategy}; -use crate::text_object::{Readonly, TextObject}; - -use std::sync::{Arc, RwLock}; use std::time::Duration; +use hac_store::collection::{BodyKind, Request}; +use hac_store::slab::EntryRef; use reqwest::header::{HeaderMap, HeaderValue}; use tokio::sync::mpsc::UnboundedSender; +use crate::net::request_strategies::http_strategy::HttpResponse; +use crate::net::request_strategies::RequestStrategy; +use crate::text_object::{Readonly, TextObject}; + #[derive(Debug, PartialEq)] pub struct Response { pub body: Option, @@ -25,7 +26,7 @@ pub struct Response { pub struct RequestManager; impl RequestManager { - pub async fn handle(strategy: S, request: Request) -> Response + pub async fn handle(strategy: S, request: &Request) -> Response where S: RequestStrategy, { @@ -58,19 +59,16 @@ impl From<&str> for ContentType { } #[tracing::instrument(skip_all)] -pub fn handle_request(request: &Arc>, response_tx: UnboundedSender) { - let request = request.read().unwrap().clone(); +pub fn handle_request(request: EntryRef, response_tx: UnboundedSender<(EntryRef, Response)>) { tokio::spawn(async move { - let response = match request.body_type.as_ref() { + let response = match request.inner.body_kind { // if we dont have a body type, this is a GET request, so we use HTTP strategy - None => RequestManager::handle(HttpResponse, request).await, - Some(body_type) => match body_type { - BodyType::Json => RequestManager::handle(HttpResponse, request).await, - }, + BodyKind::NoBody => RequestManager::handle(HttpResponse, &request.inner).await, + BodyKind::Json => RequestManager::handle(HttpResponse, &request.inner).await, }; response_tx - .send(response) + .send((request, response)) .is_err() .then(|| std::process::abort()); }); diff --git a/hac-core/src/net/request_strategies.rs b/hac-core/src/net/request_strategies.rs index f5ea4ef..df2e6f9 100755 --- a/hac-core/src/net/request_strategies.rs +++ b/hac-core/src/net/request_strategies.rs @@ -2,8 +2,9 @@ pub mod http_strategy; use std::future::Future; -use crate::{collection::types::Request, net::request_manager::Response}; +use crate::net::request_manager::Response; +use hac_store::collection::Request; pub trait RequestStrategy { - fn handle(&self, request: Request) -> impl Future; + fn handle(&self, request: &Request) -> impl Future; } diff --git a/hac-core/src/net/request_strategies/http_strategy.rs b/hac-core/src/net/request_strategies/http_strategy.rs index 7b9be5b..dd67958 100755 --- a/hac-core/src/net/request_strategies/http_strategy.rs +++ b/hac-core/src/net/request_strategies/http_strategy.rs @@ -1,27 +1,27 @@ -use crate::collection::types::{Request, RequestMethod}; use crate::net::request_client::RequestClient; use crate::net::request_manager::Response; use crate::net::request_strategies::RequestStrategy; use crate::net::response_decoders::{decoder_from_headers, ResponseDecoder}; +use hac_store::collection::{ReqMethod, Request}; pub struct HttpResponse; impl RequestStrategy for HttpResponse { - async fn handle(&self, request: Request) -> Response { + async fn handle(&self, request: &Request) -> Response { let client = RequestClient::default(); match request.method { - RequestMethod::Get => self.handle_get_request(client, request).await, - RequestMethod::Post => self.handle_post_request(client, request).await, - RequestMethod::Put => self.handle_put_request(client, request).await, - RequestMethod::Patch => self.handle_patch_request(client, request).await, - RequestMethod::Delete => self.handle_delete_request(client, request).await, + ReqMethod::Get => self.handle_get_request(client, request).await, + ReqMethod::Post => self.handle_post_request(client, request).await, + ReqMethod::Put => self.handle_put_request(client, request).await, + ReqMethod::Patch => self.handle_patch_request(client, request).await, + ReqMethod::Delete => self.handle_delete_request(client, request).await, } } } impl HttpResponse { - async fn handle_get_request(&self, client: RequestClient, request: Request) -> Response { + async fn handle_get_request(&self, client: RequestClient, request: &Request) -> Response { let now = std::time::Instant::now(); match client.get(&request).send().await { Ok(response) => { @@ -43,14 +43,9 @@ impl HttpResponse { } } - async fn handle_post_request(&self, client: RequestClient, request: Request) -> Response { + async fn handle_post_request(&self, client: RequestClient, request: &Request) -> Response { let now = std::time::Instant::now(); - match client - .post(&request) - .json(&request.body.unwrap_or_default()) - .send() - .await - { + match client.post(&request).json(&request.body).send().await { Ok(response) => { let decoder = decoder_from_headers(response.headers()); decoder.decode(response, now).await @@ -70,14 +65,9 @@ impl HttpResponse { } } - async fn handle_put_request(&self, client: RequestClient, request: Request) -> Response { + async fn handle_put_request(&self, client: RequestClient, request: &Request) -> Response { let now = std::time::Instant::now(); - match client - .put(&request) - .json(&request.body.unwrap_or_default()) - .send() - .await - { + match client.put(&request).json(&request.body).send().await { Ok(response) => { let decoder = decoder_from_headers(response.headers()); decoder.decode(response, now).await @@ -97,14 +87,9 @@ impl HttpResponse { } } - async fn handle_patch_request(&self, client: RequestClient, request: Request) -> Response { + async fn handle_patch_request(&self, client: RequestClient, request: &Request) -> Response { let now = std::time::Instant::now(); - match client - .patch(&request) - .json(&request.body.unwrap_or_default()) - .send() - .await - { + match client.patch(&request).json(&request.body).send().await { Ok(response) => { let decoder = decoder_from_headers(response.headers()); decoder.decode(response, now).await @@ -124,14 +109,9 @@ impl HttpResponse { } } - async fn handle_delete_request(&self, client: RequestClient, request: Request) -> Response { + async fn handle_delete_request(&self, client: RequestClient, request: &Request) -> Response { let now = std::time::Instant::now(); - match client - .delete(&request) - .json(&request.body.unwrap_or_default()) - .send() - .await - { + match client.delete(&request).json(&request.body).send().await { Ok(response) => { let decoder = decoder_from_headers(response.headers()); decoder.decode(response, now).await diff --git a/hac-core/src/net/response_decoders/json_decoder.rs b/hac-core/src/net/response_decoders/json_decoder.rs index 545eaf8..e814914 100755 --- a/hac-core/src/net/response_decoders/json_decoder.rs +++ b/hac-core/src/net/response_decoders/json_decoder.rs @@ -1,7 +1,9 @@ -use crate::net::{request_manager::Response, response_decoders::ResponseDecoder}; -use crate::text_object::TextObject; +use std::ops::Add; +use std::time::Instant; -use std::{ops::Add, time::Instant}; +use crate::net::request_manager::Response; +use crate::net::response_decoders::ResponseDecoder; +use crate::text_object::TextObject; pub struct JsonDecoder; @@ -19,10 +21,10 @@ impl ResponseDecoder for JsonDecoder { let mut body: Option = None; let mut pretty_body = None; - if response.content_length().is_some_and(|len| len.gt(&0)) { + if response.content_length().is_some_and(|len| len > 0) { if let Ok(body_str) = response.text().await { let pretty_body_str = jsonxf::pretty_print(&body_str).unwrap_or_default(); - pretty_body = Some(TextObject::from(&pretty_body_str)); + pretty_body = Some(TextObject::from(pretty_body_str.as_str())); body = Some(body_str); }; } diff --git a/hac-core/src/syntax.rs b/hac-core/src/syntax/mod.rs old mode 100755 new mode 100644 similarity index 100% rename from hac-core/src/syntax.rs rename to hac-core/src/syntax/mod.rs diff --git a/hac-core/src/text_object.rs b/hac-core/src/text_object.rs deleted file mode 100755 index c96231c..0000000 --- a/hac-core/src/text_object.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod cursor; -#[allow(clippy::module_inception)] -mod text_object; - -pub use text_object::{Readonly, TextObject, Write}; diff --git a/hac-core/src/text_object/cursor.rs b/hac-core/src/text_object/cursor.rs index 63b8bcc..803b17b 100755 --- a/hac-core/src/text_object/cursor.rs +++ b/hac-core/src/text_object/cursor.rs @@ -42,10 +42,15 @@ impl Cursor { } pub fn move_to_line_end(&mut self, line_len: usize) { - self.col = line_len.saturating_sub(1); + self.col = line_len + 1; self.snapback_col = self.col; } + pub fn move_to(&mut self, col: usize, row: usize) { + self.move_to_row(row); + self.move_to_col(col); + } + pub fn move_to_col(&mut self, col: usize) { self.col = col; self.snapback_col = col; @@ -91,16 +96,11 @@ impl Cursor { // when moving into a bigger line (line_len > cursor.col) we make the col snap back // to the min col between line_len and the current expand_col position. pub fn maybe_snap_to_col(&mut self, line_len: usize) { - match line_len.saturating_sub(1).cmp(&self.col) { + match line_len.cmp(&self.col) { std::cmp::Ordering::Less => { - if self.snapback_col.eq(&self.col) { - self.snapback_col = self.col; - } - self.col = line_len.saturating_sub(1); - } - std::cmp::Ordering::Greater => { - self.col = self.snapback_col.min(line_len.saturating_sub(1)) + self.col = line_len + 1; } + std::cmp::Ordering::Greater => self.col = self.snapback_col.min(line_len.saturating_sub(1)), // if both expand_col and col are the same we dont have to do nothing std::cmp::Ordering::Equal => {} } diff --git a/hac-core/src/text_object/text_object.rs b/hac-core/src/text_object/mod.rs old mode 100755 new mode 100644 similarity index 91% rename from hac-core/src/text_object/text_object.rs rename to hac-core/src/text_object/mod.rs index ed966ef..479f6a2 --- a/hac-core/src/text_object/text_object.rs +++ b/hac-core/src/text_object/mod.rs @@ -1,4 +1,4 @@ -use crate::{syntax::highlighter::Highlighter, text_object::cursor::Cursor}; +pub mod cursor; use std::collections::HashMap; use std::ops::{Add, Sub}; @@ -6,6 +6,9 @@ use std::ops::{Add, Sub}; use ropey::Rope; use tree_sitter::Tree; +use crate::syntax::highlighter::Highlighter; +use crate::text_object::cursor::Cursor; + #[derive(Debug, PartialEq, Clone)] pub enum LineBreak { Lf, @@ -46,7 +49,7 @@ impl Default for TextObject { fn default() -> Self { let content = String::default(); - TextObject { + Self { content: Rope::from_str(&content), state: std::marker::PhantomData, line_break: LineBreak::Lf, @@ -54,8 +57,14 @@ impl Default for TextObject { } } +impl> From for TextObject { + fn from(value: T) -> Self { + Self::new(value.as_ref()) + } +} + impl TextObject { - pub fn from(content: &str) -> TextObject { + pub fn new(content: &str) -> TextObject { let content = Rope::from_str(content); let line_break = match content.line(0).to_string().contains("\r\n") { true => LineBreak::Crlf, @@ -87,8 +96,7 @@ impl TextObject { pub fn insert_newline(&mut self, cursor: &Cursor) { let line = self.content.line_to_char(cursor.row()); let col_offset = line + cursor.col(); - self.content - .insert(col_offset, &self.line_break.to_string()); + self.content.insert(col_offset, &self.line_break.to_string()); } pub fn erase_backwards_up_to_line_start(&mut self, cursor: &Cursor) { @@ -97,17 +105,13 @@ impl TextObject { } let line = self.content.line_to_char(cursor.row()); let col_offset = line + cursor.col(); - self.content - .try_remove(col_offset.saturating_sub(1)..col_offset) - .ok(); + self.content.try_remove(col_offset.saturating_sub(1)..col_offset).ok(); } pub fn erase_previous_char(&mut self, cursor: &Cursor) { let line = self.content.line_to_char(cursor.row()); let col_offset = line + cursor.col(); - self.content - .try_remove(col_offset.saturating_sub(1)..col_offset) - .ok(); + self.content.try_remove(col_offset.saturating_sub(1)..col_offset).ok(); } pub fn erase_current_char(&mut self, cursor: &Cursor) { @@ -140,9 +144,12 @@ impl TextObject { let line = self.content.line_to_char(cursor.row()); let next_line = self.content.line_to_char(cursor.row().add(1)); let col_offset = line + cursor.col(); - self.content - .try_remove(col_offset..next_line.saturating_sub(1)) - .ok(); + self.content.try_remove(col_offset..next_line.saturating_sub(1)).ok(); + } + + pub fn clear_line(&mut self, cursor: &Cursor) { + let line = self.content.line_to_char(cursor.row()); + self.content.try_remove(line..line + cursor.col()).ok(); } pub fn find_char_after_whitespace(&self, cursor: &Cursor) -> (usize, usize) { @@ -198,11 +205,7 @@ impl TextObject { if let Some(initial_char) = self.content.get_char(start_idx) { for char in self.content.chars_at(start_idx) { - match ( - initial_char.is_alphanumeric(), - char.is_alphanumeric(), - found_newline, - ) { + match (initial_char.is_alphanumeric(), char.is_alphanumeric(), found_newline) { (_, _, true) if !char.is_whitespace() => break, (false, true, _) => break, (true, false, _) => break, @@ -232,11 +235,7 @@ impl TextObject { for _ in (0..start_idx.saturating_sub(1)).rev() { let char = self.content.char(end_idx); - match ( - initial_char.is_alphanumeric(), - char.is_alphanumeric(), - found_newline, - ) { + match (initial_char.is_alphanumeric(), char.is_alphanumeric(), found_newline) { (_, _, true) if !self.line_break.to_string().contains(char) => break, (false, true, _) => break, (true, false, _) => break, @@ -392,20 +391,12 @@ impl TextObject { _ => {} } - let range = if look_forward { - start_idx.add(1)..self.content.len_chars() - } else { - 0..start_idx - }; + let range = if look_forward { start_idx.add(1)..self.content.len_chars() } else { 0..start_idx }; for i in range { let char = self .content - .get_char(if look_forward { - i - } else { - start_idx - walked - 1 - }) + .get_char(if look_forward { i } else { start_idx - walked - 1 }) .unwrap_or_default(); if token_to_search.eq(&char::default()) { @@ -419,8 +410,7 @@ impl TextObject { char.eq(combinations.get(&token_to_search).unwrap()) .then(|| curr_open = curr_open.add(1)); - char.eq(&token_to_search) - .then(|| curr_open = curr_open.sub(1)); + char.eq(&token_to_search).then(|| curr_open = curr_open.sub(1)); walked = walked.add(1); diff --git a/hac-loader/Cargo.toml b/hac-loader/Cargo.toml new file mode 100644 index 0000000..680af6a --- /dev/null +++ b/hac-loader/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "hac-loader" +version = "0.2.0" +edition = "2021" + +[dependencies] +hac-store.workspace = true +hac-config.workspace = true + +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +tracing.workspace = true +dirs = "5.0.1" +notify = "6.1.1" diff --git a/hac-loader/src/collection_loader/error.rs b/hac-loader/src/collection_loader/error.rs new file mode 100644 index 0000000..cfdde28 --- /dev/null +++ b/hac-loader/src/collection_loader/error.rs @@ -0,0 +1,24 @@ +pub type Result = std::result::Result; + +#[derive(Debug)] +pub enum Error { + Create(String), + Rename(String), + Remove(String), + ReadDir(String), + Read(String), +} + +impl std::error::Error for Error {} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Create(message) => write!(f, "{message}"), + Self::Rename(message) => write!(f, "{message}"), + Self::Remove(message) => write!(f, "{message}"), + Self::ReadDir(message) => write!(f, "{message}"), + Self::Read(message) => write!(f, "{message}"), + } + } +} diff --git a/hac-loader/src/collection_loader/json_collection.rs b/hac-loader/src/collection_loader/json_collection.rs new file mode 100644 index 0000000..59de473 --- /dev/null +++ b/hac-loader/src/collection_loader/json_collection.rs @@ -0,0 +1,89 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct JsonInfo { + pub name: String, + pub path: std::path::PathBuf, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JsonCollection { + pub requests: Vec, + #[serde(skip)] + pub file_info: JsonInfo, +} + +impl JsonCollection { + pub fn new

(file_name: String, path: P) -> Self + where + P: AsRef, + { + Self { + file_info: JsonInfo { + name: file_name, + path: path.as_ref().to_path_buf(), + }, + requests: Default::default(), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ReqKind { + Req(JsonRequest), + Folder(JsonFolder), +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JsonRequest { + pub name: String, + pub method: JsonReqMethod, + pub uri: String, + pub headers: Vec, + #[serde(rename = "authKind")] + pub auth_kind: JsonAuthKind, + #[serde(rename = "bodyKind")] + pub body_kind: JsonBodyKind, + pub body: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JsonHeaderEntry { + pub key: String, + pub val: String, + pub enabled: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum JsonBodyKind { + Json, + #[serde(rename = "NO_BODY")] + NoBody, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum JsonReqMethod { + Get, + Post, + Put, + Patch, + Delete, +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum JsonAuthKind { + Bearer, + #[serde(rename = "NO_AUTH")] + NoAuth, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JsonFolder { + pub name: String, + pub requests: Vec, +} diff --git a/hac-loader/src/collection_loader/json_loader.rs b/hac-loader/src/collection_loader/json_loader.rs new file mode 100644 index 0000000..810edf1 --- /dev/null +++ b/hac-loader/src/collection_loader/json_loader.rs @@ -0,0 +1,182 @@ +use hac_store::collection::{ + BodyKind, Collection, Folder, HeaderEntry, ReqMethod, ReqTree, ReqTreeNode, Request, WhichSlab, +}; +use hac_store::slab::{Key, Slab}; + +use super::json_collection::{ + JsonBodyKind, JsonCollection, JsonFolder, JsonHeaderEntry, JsonReqMethod, JsonRequest, ReqKind, +}; +use super::{IntoCollection, Result}; + +pub struct JsonLoader(JsonCollection); + +impl IntoCollection for JsonLoader { + fn into_collection(self) -> hac_store::collection::Collection { + from_file_to_collection(self.0) + } +} + +impl JsonLoader { + pub fn parse(content: &str) -> Result { + match serde_json::from_str(content) { + Ok(collection) => Ok(Self(collection)), + Err(e) => todo!("{e:?}"), + } + } +} + +fn from_file_to_collection(file_collection: JsonCollection) -> Collection { + let mut folders = Slab::::new(); + let mut requests = Slab::::new(); + let mut root_requests = Slab::::new(); + + for item in file_collection.requests { + match item { + ReqKind::Req(inner) => _ = root_requests.push(inner.into()), + ReqKind::Folder(inner) => _ = collect_folder(inner, &mut requests, &mut folders), + }; + } + + let selected_request = match (root_requests.is_empty(), folders.is_empty()) { + (true, true) => None, + (false, _) => Some((WhichSlab::RootRequests, 0)), + (true, false) => { + let folder = folders.get(0); + match folder.requests.is_empty() { + true => Some((WhichSlab::Folders, 0)), + false => Some((WhichSlab::Requests, folder.requests[0])), + } + } + }; + + let mut nodes: Vec = (0..root_requests.len()).map(ReqTreeNode::Req).collect(); + nodes.extend( + folders + .iter() + .enumerate() + .map(|(idx, folder)| ReqTreeNode::Folder(idx, folder.requests.clone())) + .collect::>(), + ); + + let layout = ReqTree { nodes }; + + Collection { + folders, + requests, + root_requests, + layout, + hovered_request: selected_request, + selected_request, + } +} + +fn collect_folder(file_folder: JsonFolder, requests: &mut Slab, folders: &mut Slab) -> Key { + // inserting a folder to be modified later to ensure we reserve an idx + let idx = folders.push(Folder::default()); + let mut childrens = vec![]; + + for item in file_folder.requests { + let mut req: Request = item.into(); + req.parent = Some(idx); + let req_idx = requests.push(req); + childrens.push(req_idx); + } + + let folder = folders.get_mut(idx); + folder.name = file_folder.name; + folder.requests = childrens; + idx +} + +impl From for BodyKind { + fn from(body_kind: JsonBodyKind) -> Self { + match body_kind { + JsonBodyKind::Json => Self::Json, + JsonBodyKind::NoBody => Self::NoBody, + } + } +} + +impl From for HeaderEntry { + fn from(entry: JsonHeaderEntry) -> Self { + Self { + key: entry.key, + val: entry.val, + enabled: entry.enabled, + } + } +} + +impl From for ReqMethod { + fn from(file_req: JsonReqMethod) -> Self { + match file_req { + JsonReqMethod::Get => Self::Get, + JsonReqMethod::Post => Self::Post, + JsonReqMethod::Put => Self::Put, + JsonReqMethod::Patch => Self::Patch, + JsonReqMethod::Delete => Self::Delete, + } + } +} + +impl From for Request { + fn from(file_req: JsonRequest) -> Self { + Self { + parent: None, + body: file_req.body, + name: file_req.name, + uri: file_req.uri, + body_kind: file_req.body_kind.into(), + method: file_req.method.into(), + headers: file_req.headers.into_iter().map(Into::into).collect::>(), + } + } +} + +impl From for Folder { + fn from(file_folder: JsonFolder) -> Self { + Self { + name: file_folder.name, + requests: vec![], + collapsed: false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // simple collection with just a few entries + const BASE_COLLECTION: &str = include_str!("../../test_collections/basic.json"); + // collection with complex structure of directories and requests + const FULL_COLLECTION: &str = include_str!("../../test_collections/complex.json"); + + #[test] + fn load_from_json_to_inner_collection() { + let result = JsonLoader::parse(BASE_COLLECTION).unwrap(); + let collection = result.into_collection(); + + assert!(collection.requests.len() == 1); + assert!(collection.folders.len() == 1); + assert!(collection.root_requests.len() == 1); + assert_eq!(collection.root_requests.get(0).name, "Sample Root Request"); + assert_eq!(collection.requests.get(0).name, "Sample Parented Request"); + assert_eq!(collection.folders.get(0).name, "Sample Folder"); + + let result = JsonLoader::parse(FULL_COLLECTION).unwrap(); + let collection = result.into_collection(); + + assert!(collection.requests.len() == 4); + assert!(collection.folders.len() == 3); + assert!(collection.root_requests.len() == 1); + assert_eq!(collection.root_requests.get(0).name, "Root Request 1"); + assert_eq!(collection.requests.get(0).name, "Parented Request 1 Folder 1 - 1"); + assert_eq!(collection.requests.get(1).name, "Parented Request 1 Folder 1 - 2"); + assert_eq!(collection.requests.get(2).name, "Parented Request 2 Folder 1 - 1"); + assert_eq!(collection.requests.get(3).name, "Parented Request 1 Folder 1"); + assert_eq!(collection.folders.get(0).name, "Folder 1"); + assert_eq!(collection.folders.get(1).name, "Folder 1 - 1"); + assert_eq!(collection.folders.get(2).name, "Folder 1 - 2"); + } +} diff --git a/hac-loader/src/collection_loader/mod.rs b/hac-loader/src/collection_loader/mod.rs new file mode 100644 index 0000000..6436307 --- /dev/null +++ b/hac-loader/src/collection_loader/mod.rs @@ -0,0 +1,162 @@ +mod error; +mod json_collection; +mod json_loader; + +use std::cell::RefCell; +use std::fs; +use std::path::Path; +use std::rc::Rc; +use std::sync::atomic::{AtomicBool, Ordering}; + +pub use error::{Error, Result}; +use hac_config::config::CollectionExtensions; +use hac_store::collection::Collection; +use hac_store::collection_meta::{CollectionMeta, CollectionModifiedMeta}; +use json_loader::JsonLoader; +use notify::{RecursiveMode, Watcher}; + +static HAS_CHANGES: AtomicBool = AtomicBool::new(false); +static HAS_WATCHER: AtomicBool = AtomicBool::new(false); + +pub trait IntoCollection { + fn into_collection(self) -> hac_store::collection::Collection; +} + +pub fn read_collection_file(file_path: F, parser: P) -> Result +where + F: AsRef, + P: FnOnce(&str) -> Result, + T: IntoCollection, +{ + match std::fs::read_to_string(file_path.as_ref()) { + Ok(content) => Ok(parser(&content)?.into_collection()), + Err(e) => todo!("{e:?}"), + } +} + +pub fn load_collection>(file_path: F, config: &Rc>) -> Result { + match config.borrow().collection_ext { + CollectionExtensions::Json => Ok(read_collection_file(file_path, JsonLoader::parse)?), + } +} + +fn sanitize_filename(name: &str) -> String { + // TODO: find a better way to do this + let forbidden_chars = ['/', '\\', '?', '%', '*', ':', '|', '"', '<', '>', '.']; + name.chars() + .map(|c| if forbidden_chars.contains(&c) { '_' } else { c }) + .collect() +} + +fn create_persistent_colletion(name: String) -> Result<()> { + // TODO: make this dynamic + let file_name = format!("{}.json", sanitize_filename(&name)); + tracing::debug!("creating new collection {file_name} on disk"); + let path = super::collections_dir().join(&file_name); + let collection = json_collection::JsonCollection::new(file_name, &path); + let strigified = serde_json::to_string_pretty(&collection).expect("invalid collection format to be stringified"); + fs::write(&path, strigified).map_err(|_| Error::Create("failed to write collection to disk".into()))?; + Ok(()) +} + +pub fn has_changes() -> bool { + HAS_CHANGES.load(Ordering::Relaxed) +} + +fn set_watcher() { + if HAS_WATCHER.load(Ordering::Relaxed) { + return; + } + + if let Ok(mut watcher) = notify::recommended_watcher(|res| match res { + Ok(_) => HAS_CHANGES.store(true, Ordering::Relaxed), + Err(_) => tracing::error!("failed to get changes from watcher"), + }) { + std::thread::spawn(move || { + let collections_dir = super::collections_dir(); + if let Err(e) = watcher.watch(&collections_dir, RecursiveMode::NonRecursive) { + tracing::error!("failed to watch collections dir: {}", e); + return; + } + loop { + std::thread::sleep(std::time::Duration::from_secs(1)); + } + }); + }; +} + +fn create_virtual_collection(name: String) { + let file_name = format!("{}.json", sanitize_filename(&name)); + let size = name.len(); + let collection_meta = CollectionMeta::new(name, file_name.into(), size as u64, CollectionModifiedMeta::new()); + hac_store::collection_meta::push_collection_meta(collection_meta) +} + +pub fn create_collection(name: String, config: &Rc>) -> Result<()> { + match config.borrow().dry_run { + false => create_persistent_colletion(name)?, + true => create_virtual_collection(name), + } + Ok(()) +} + +fn edit_persistent_collection(entry: &mut CollectionMeta) -> Result<()> { + let original_path = entry.path().clone(); + entry.path_mut().pop(); + let name = entry.name().to_string(); + let new_path = entry.path_mut().join(name); + std::fs::rename(original_path, new_path).map_err(|_| Error::Rename("failed to rename collection".into()))?; + Ok(()) +} + +pub fn edit_collection(name: String, item_idx: usize, config: &Rc>) -> Result<()> { + hac_store::collection_meta::get_collection_meta_mut(item_idx, |entry| -> Result<()> { + match config.borrow().dry_run { + false => edit_persistent_collection(entry), + true => { + entry.path_mut().pop(); + *entry.path_mut() = entry.path().join(&name); + *entry.name_mut() = name; + Ok(()) + } + } + }) +} + +fn delete_persistent_collection(file_name: String) -> Result<()> { + let collection_meta = hac_store::collection_meta::remove_collection_meta(file_name, |meta| meta); + std::fs::remove_file(collection_meta.path()).map_err(|_| Error::Remove("failed to remove collection".into()))?; + Ok(()) +} + +pub fn delete_collection(file_name: String, config: &Rc>) -> Result<()> { + match config.borrow().dry_run { + false => delete_persistent_collection(file_name)?, + true => hac_store::collection_meta::remove_collection_meta(file_name, |_| ()), + } + Ok(()) +} + +pub fn get_collections_metadata() -> Result<()> { + set_watcher(); + let entries = std::fs::read_dir(super::collections_dir()) + .map_err(|_| Error::ReadDir("failed to read collections directory".into()))? + .flatten(); + let mut collections = vec![]; + for entry in entries { + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + let metadata = entry + .metadata() + .map_err(|_| Error::Read("failed to read collection metadata".into()))?; + let modified = metadata + .modified() + .map_err(|_| Error::Read("failed to read collection metadata".into()))? + .into(); + let size = metadata.len(); + collections.push(CollectionMeta::new(name, path, size, modified)); + } + hac_store::collection_meta::set_collections_meta(collections); + HAS_CHANGES.store(false, Ordering::Relaxed); + Ok(()) +} diff --git a/hac-config/src/data.rs b/hac-loader/src/lib.rs old mode 100755 new mode 100644 similarity index 76% rename from hac-config/src/data.rs rename to hac-loader/src/lib.rs index 9b928c0..ee6002a --- a/hac-config/src/data.rs +++ b/hac-loader/src/lib.rs @@ -1,8 +1,9 @@ -use crate::{APP_NAME, COLLECTIONS_DIR, XDG_DEFAULTS, XDG_ENV_VARS}; +pub mod collection_loader; +use hac_config::{APP_NAME, COLLECTIONS_DIR, XDG_DEFAULTS, XDG_ENV_VARS}; use std::path::PathBuf; -pub fn get_data_dir() -> PathBuf { +pub fn data_dir() -> PathBuf { let data_dir = std::env::var(XDG_ENV_VARS[1]) .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from(XDG_DEFAULTS[1])); @@ -14,7 +15,7 @@ pub fn get_data_dir() -> PathBuf { } pub fn get_or_create_data_dir() -> PathBuf { - let data_dir = get_data_dir(); + let data_dir = data_dir(); if !data_dir.exists() && !data_dir.is_dir() { match std::fs::create_dir(&data_dir) { @@ -32,13 +33,14 @@ pub fn get_or_create_data_dir() -> PathBuf { data_dir } -pub fn get_collections_dir() -> PathBuf { - let data_dir = get_data_dir(); +pub fn collections_dir() -> PathBuf { + let data_dir = data_dir(); data_dir.join(COLLECTIONS_DIR) } +#[tracing::instrument] pub fn get_or_create_collections_dir() -> PathBuf { - let collections_dir = get_collections_dir(); + let collections_dir = collections_dir(); if !collections_dir.exists() && !collections_dir.is_dir() { match std::fs::create_dir(&collections_dir) { @@ -47,7 +49,6 @@ pub fn get_or_create_collections_dir() -> PathBuf { // if we fail to do so, panicking is adequate as we won't be able to properly run the // application Err(_) => { - tracing::error!("failed to create collections_dir at: {collections_dir:?}"); panic!("failed to create collections_dir at: {collections_dir:?}"); } } @@ -55,7 +56,3 @@ pub fn get_or_create_collections_dir() -> PathBuf { collections_dir } - -pub fn log_file() -> (PathBuf, String) { - (get_data_dir(), format!("{}.log", APP_NAME)) -} diff --git a/hac-loader/test_collections/basic.json b/hac-loader/test_collections/basic.json new file mode 100644 index 0000000..6290ab6 --- /dev/null +++ b/hac-loader/test_collections/basic.json @@ -0,0 +1,37 @@ +{ + "info": { + "name": "Basic Collection", + "description": "Basic Collection Description" + }, + "requests": [ + { + "name": "Sample Folder", + "requests": [ + { + "name": "Sample Parented Request", + "method": "POST", + "uri": "https://example.com", + "headers": [ + { "key": "Sample Header 1", "val": "Header Value 1", "enabled": true }, + { "key": "Sample Header 2", "val": "Header Value 2", "enabled": false } + ], + "authKind": "NO_AUTH", + "bodyKind": "JSON", + "body": "{\"sampleJsonBody\": { \"sampleKey\": \"sampleValue\" } }" + } + ] + }, + { + "name": "Sample Root Request", + "method": "POST", + "uri": "https://example.com", + "headers": [ + { "key": "Sample Header 1", "val": "Header Value 1", "enabled": true }, + { "key": "Sample Header 2", "val": "Header Value 2", "enabled": false } + ], + "authKind": "NO_AUTH", + "bodyKind": "NO_BODY", + "body": "{\"sampleJsonBody\": { \"sampleKey\": \"sampleValue\" } }" + } + ] +} diff --git a/hac-loader/test_collections/complex.json b/hac-loader/test_collections/complex.json new file mode 100644 index 0000000..4ef9949 --- /dev/null +++ b/hac-loader/test_collections/complex.json @@ -0,0 +1,84 @@ +{ + "info": { + "name": "Basic Collection", + "description": "Basic Collection Description" + }, + "requests": [ + { + "name": "Root Request 1", + "method": "POST", + "uri": "https://example.com", + "headers": [ + { "key": "Sample Header 1", "val": "Header Value 1", "enabled": true }, + { "key": "Sample Header 2", "val": "Header Value 2", "enabled": false } + ], + "authKind": "NO_AUTH", + "bodyKind": "NO_BODY", + "body": "{\"sampleJsonBody\": { \"sampleKey\": \"sampleValue\" } }" + }, + { + "name": "Folder 1", + "requests": [ + { + "name": "Folder 1 - 1", + "requests": [ + { + "name": "Parented Request 1 Folder 1 - 1", + "method": "POST", + "uri": "https://example.com", + "headers": [ + { "key": "Sample Header 1", "val": "Header Value 1", "enabled": true }, + { "key": "Sample Header 2", "val": "Header Value 2", "enabled": false } + ], + "authKind": "NO_AUTH", + "bodyKind": "JSON", + "body": "{\"sampleJsonBody\": { \"sampleKey\": \"sampleValue\" } }" + }, + { + "name": "Folder 1 - 2", + "requests": [ + { + "name": "Parented Request 1 Folder 1 - 2", + "method": "POST", + "uri": "https://example.com", + "headers": [ + { "key": "Sample Header 1", "val": "Header Value 1", "enabled": true }, + { "key": "Sample Header 2", "val": "Header Value 2", "enabled": false } + ], + "authKind": "NO_AUTH", + "bodyKind": "JSON", + "body": "{\"sampleJsonBody\": { \"sampleKey\": \"sampleValue\" } }" + } + ] + }, + { + "name": "Parented Request 2 Folder 1 - 1", + "method": "POST", + "uri": "https://example.com", + "headers": [ + { "key": "Sample Header 1", "val": "Header Value 1", "enabled": true }, + { "key": "Sample Header 2", "val": "Header Value 2", "enabled": false } + ], + "authKind": "NO_AUTH", + "bodyKind": "JSON", + "body": "{\"sampleJsonBody\": { \"sampleKey\": \"sampleValue\" } }" + } + ] + }, + { + "name": "Parented Request 1 Folder 1", + "method": "POST", + "uri": "https://example.com", + "headers": [ + { "key": "Sample Header 1", "val": "Header Value 1", "enabled": true }, + { "key": "Sample Header 2", "val": "Header Value 2", "enabled": false } + ], + "authKind": "NO_AUTH", + "bodyKind": "JSON", + "body": "{\"sampleJsonBody\": { \"sampleKey\": \"sampleValue\" } }" + } + ] + } + ] +} + diff --git a/hac-store/Cargo.toml b/hac-store/Cargo.toml new file mode 100644 index 0000000..f7e4d2f --- /dev/null +++ b/hac-store/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "hac-store" +version = "0.2.0" +edition = "2021" + +[dependencies] +serde.workspace = true +chrono = "0.4.38" +tracing.workspace = true diff --git a/hac-store/src/collection.rs b/hac-store/src/collection.rs new file mode 100644 index 0000000..a54ab04 --- /dev/null +++ b/hac-store/src/collection.rs @@ -0,0 +1,664 @@ +use std::fmt::Display; + +use crate::slab::{EntryRef, Key, Slab}; +use crate::HAC_STORE; + +#[derive(Debug, Clone)] +pub enum ReqTreeNode { + Req(Key), + Folder(Key, Vec), +} + +#[derive(Debug, Clone)] +pub struct ReqTree { + pub nodes: Vec, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum EntryStatus { + None, + Hovered, + Selected, + Both, +} + +impl From<(bool, bool)> for EntryStatus { + fn from((hovered, selected): (bool, bool)) -> Self { + match (hovered, selected) { + (false, false) => Self::None, + (true, false) => Self::Hovered, + (false, true) => Self::Selected, + (true, true) => Self::Both, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WhichSlab { + Requests, + Folders, + RootRequests, +} + +#[derive(Debug)] +pub struct Collection { + pub requests: Slab, + pub root_requests: Slab, + pub folders: Slab, + pub layout: ReqTree, + pub selected_request: Option<(WhichSlab, Key)>, + pub hovered_request: Option<(WhichSlab, Key)>, +} + +impl Collection { + pub fn is_empty(&self) -> bool { + self.requests.is_empty() && self.root_requests.is_empty() + } +} + +#[derive(Debug, Default, PartialEq, Eq, Copy, Clone)] +pub enum ReqMethod { + #[default] + Get, + Post, + Put, + Patch, + Delete, +} + +impl ReqMethod { + pub fn size() -> usize { + 5 + } + + pub fn iter() -> impl Iterator { + [ + ReqMethod::Get, + ReqMethod::Post, + ReqMethod::Put, + ReqMethod::Patch, + ReqMethod::Delete, + ] + .into_iter() + } + + pub fn set_first(&mut self) { + *self = ReqMethod::Get; + } + + pub fn set_last(&mut self) { + *self = ReqMethod::Delete; + } + + pub fn set_next(&mut self) { + match self { + ReqMethod::Get => *self = ReqMethod::Post, + ReqMethod::Post => *self = ReqMethod::Put, + ReqMethod::Put => *self = ReqMethod::Patch, + ReqMethod::Patch => *self = ReqMethod::Delete, + ReqMethod::Delete => *self = ReqMethod::Get, + } + } + + pub fn set_prev(&mut self) { + match self { + ReqMethod::Get => *self = ReqMethod::Delete, + ReqMethod::Post => *self = ReqMethod::Get, + ReqMethod::Put => *self = ReqMethod::Post, + ReqMethod::Patch => *self = ReqMethod::Put, + ReqMethod::Delete => *self = ReqMethod::Patch, + } + } +} + +impl From for ReqMethod { + fn from(value: char) -> Self { + match value { + '1' => ReqMethod::Get, + '2' => ReqMethod::Post, + '3' => ReqMethod::Put, + '4' => ReqMethod::Patch, + '5' => ReqMethod::Delete, + _ => unreachable!(), + } + } +} + +impl Display for ReqMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReqMethod::Get => f.write_str("GET"), + ReqMethod::Post => f.write_str("POST"), + ReqMethod::Put => f.write_str("PUT"), + ReqMethod::Patch => f.write_str("PATCH"), + ReqMethod::Delete => f.write_str("DELETE"), + } + } +} + +#[derive(Debug)] +pub struct HeaderEntry { + pub key: String, + pub val: String, + pub enabled: bool, +} + +#[derive(Debug, Default)] +pub enum BodyKind { + #[default] + NoBody, + Json, +} + +#[derive(Debug, Default)] +pub struct Request { + pub name: String, + pub method: ReqMethod, + pub parent: Option, + pub uri: String, + pub headers: Vec, + pub body: String, + pub body_kind: BodyKind, +} + +impl Request { + pub fn new(name: String, method: ReqMethod, parent: Option) -> Self { + Self { + name, + method, + parent, + ..Default::default() + } + } +} + +#[derive(Debug, Default)] +pub struct Folder { + pub name: String, + pub requests: Vec, + pub collapsed: bool, +} + +pub fn set_collection(collection: Option) { + HAC_STORE.with_borrow_mut(|store| store.collection = collection); +} + +pub fn get_selected_entry() -> Option<(WhichSlab, Key)> { + HAC_STORE.with_borrow(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_ref().unwrap(); + collection.selected_request + }) +} + +pub fn get_selected_request(f: F) -> Option +where + F: FnOnce(&Request) -> Option, +{ + HAC_STORE.with_borrow(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_ref().unwrap(); + if let Some((slab, key)) = collection.selected_request { + if let Some(request) = match slab { + WhichSlab::Requests => Some(collection.requests.get(key)), + WhichSlab::RootRequests => Some(collection.root_requests.get(key)), + WhichSlab::Folders => None, + } { + return f(request); + } + } + + None + }) +} + +pub fn get_selected_request_mut(f: F) +where + F: FnOnce(&mut Request), +{ + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + if let Some((slab, key)) = collection.selected_request { + if let Some(request) = match slab { + WhichSlab::Requests => Some(collection.requests.get_mut(key)), + WhichSlab::RootRequests => Some(collection.root_requests.get_mut(key)), + WhichSlab::Folders => None, + } { + f(request); + } + } + }) +} + +pub fn is_empty() -> bool { + HAC_STORE.with_borrow(|store| { + store + .collection + .as_ref() + .is_some_and(|collection| collection.is_empty()) + }) +} + +pub fn tree_layout() -> ReqTree { + let mut nodes = ReqTree { nodes: vec![] }; + + HAC_STORE.with_borrow(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_ref().unwrap(); + nodes = collection.layout.clone(); + }); + + nodes +} + +pub fn remove_request(key: Key) -> Request { + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + let req = collection.requests.remove(key); + assert!(req.parent.is_some()); + let folder = collection.folders.get_mut(req.parent.unwrap()); + folder.requests.retain(|r| *r != key); + req + }) +} + +pub fn remove_root_request(key: Key) -> Request { + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + collection.root_requests.remove(key) + }) +} + +pub fn remove_folder(key: Key) -> Folder { + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + let folder = collection.folders.remove(key); + for req in folder.requests.iter() { + collection.requests.remove(*req); + } + folder + }) +} + +pub fn get_root_request(key: Key, f: F) -> R +where + F: FnOnce(&Request, EntryStatus) -> R, +{ + HAC_STORE.with_borrow(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_ref().unwrap(); + let req = collection.root_requests.get(key); + let is_hovered = collection + .hovered_request + .is_some_and(|(slab, k)| matches!(slab, WhichSlab::RootRequests) && key == k); + let is_selected = collection + .selected_request + .is_some_and(|(slab, k)| matches!(slab, WhichSlab::RootRequests) && key == k); + f(req, (is_hovered, is_selected).into()) + }) +} + +pub fn get_root_request_mut(key: Key, f: F) +where + F: FnOnce(&mut Request, EntryStatus), +{ + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + let req = collection.root_requests.get_mut(key); + let is_hovered = collection + .hovered_request + .is_some_and(|(slab, k)| matches!(slab, WhichSlab::RootRequests) && key == k); + let is_selected = collection + .selected_request + .is_some_and(|(slab, k)| matches!(slab, WhichSlab::RootRequests) && key == k); + f(req, (is_hovered, is_selected).into()); + }) +} + +pub fn get_request(key: Key, f: F) -> R +where + F: FnOnce(&Request, EntryStatus) -> R, +{ + HAC_STORE.with_borrow(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_ref().unwrap(); + let req = collection.requests.get(key); + let is_hovered = collection + .hovered_request + .is_some_and(|(slab, k)| matches!(slab, WhichSlab::Requests) && key == k); + let is_selected = collection + .selected_request + .is_some_and(|(slab, k)| matches!(slab, WhichSlab::Requests) && key == k); + f(req, (is_hovered, is_selected).into()) + }) +} + +pub fn get_request_mut(key: Key, f: F) +where + F: FnOnce(&mut Request, EntryStatus), +{ + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + let req = collection.requests.get_mut(key); + let is_hovered = collection + .hovered_request + .is_some_and(|(slab, k)| matches!(slab, WhichSlab::Requests) && key == k); + let is_selected = collection + .selected_request + .is_some_and(|(slab, k)| matches!(slab, WhichSlab::Requests) && key == k); + f(req, (is_hovered, is_selected).into()); + }) +} + +pub fn folders(f: F) +where + F: FnMut(&Folder), +{ + HAC_STORE.with_borrow(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_ref().unwrap(); + collection.folders.iter().for_each(f) + }) +} + +pub fn len_folders() -> usize { + HAC_STORE.with_borrow(|store| store.collection.as_ref().map(|c| c.folders.len()).unwrap_or_default()) +} + +pub fn has_folders() -> bool { + len_folders() != 0 +} + +pub fn get_folder(key: Key, f: F) -> R +where + F: FnOnce(&Folder, EntryStatus) -> R, +{ + HAC_STORE.with_borrow(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_ref().unwrap(); + let folder = collection.folders.get(key); + let is_hovered = collection + .hovered_request + .is_some_and(|(slab, k)| matches!(slab, WhichSlab::Folders) && key == k); + let is_selected = collection + .selected_request + .is_some_and(|(slab, k)| matches!(slab, WhichSlab::Folders) && key == k); + f(folder, (is_hovered, is_selected).into()) + }) +} + +pub fn get_folder_mut(key: Key, f: F) +where + F: FnOnce(&mut Folder, EntryStatus), +{ + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + let folder = collection.folders.get_mut(key); + let is_hovered = collection + .hovered_request + .is_some_and(|(slab, k)| matches!(slab, WhichSlab::Folders) && key == k); + let is_selected = collection + .selected_request + .is_some_and(|(slab, k)| matches!(slab, WhichSlab::Folders) && key == k); + f(folder, (is_hovered, is_selected).into()); + }) +} + +// TODO: this sucks, rewrite it probably +pub fn hover_next() { + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + match collection.hovered_request { + None => { + if let Some(node) = collection.layout.nodes.first() { + match node { + ReqTreeNode::Req(key) => collection.hovered_request = Some((WhichSlab::RootRequests, *key)), + ReqTreeNode::Folder(key, _) => collection.hovered_request = Some((WhichSlab::Folders, *key)), + } + } + } + Some((which, key)) => match which { + WhichSlab::Requests => { + let req = collection.requests.get(key); + let parent_key = req.parent.expect("nested request has no parent"); + let parent = collection.folders.get(parent_key); + let pos = parent + .requests + .iter() + .position(|&req| req == key) + .expect("nested request not listed on the parent"); + + // when the current nested request is not the last on the folder, we hover + // the next request on that folder + if pos < parent.requests.len() - 1 { + collection.hovered_request = Some((WhichSlab::Requests, parent.requests[pos + 1])); + return; + } + + // when it is the last one, we try to move to the next folder if possible + if let Some(next) = collection + .folders + .try_get(parent_key + 1) + .map(|_| (WhichSlab::Folders, parent_key + 1)) + { + collection.hovered_request = Some(next) + }; + } + WhichSlab::Folders => { + let folder = collection.folders.get(key); + // when the folder is collapsed or has no requests, and its not the + // last folder on the collection, we can hover the next folder + if (folder.requests.is_empty() || folder.collapsed) && key < collection.folders.len() - 1 { + let key = collection.folders.next_key(key); + collection.hovered_request = Some((WhichSlab::Folders, key)); + return; + } + + // when the folder has requests, we hover the first one, unless the folder + // is collapsed + if !folder.requests.is_empty() && !folder.collapsed { + collection.hovered_request = Some((WhichSlab::Requests, folder.requests[0])); + } + + // when the folder has no requests and its the last folder, we don't do + // anything + } + WhichSlab::RootRequests => { + // when the last root_request is hovered, we try to hover the first folder + // as no request with a parent can appear before its parent + if key >= collection.root_requests.len() - 1 { + collection.hovered_request = collection.folders.try_get(0).map(|_| (WhichSlab::Folders, 0)); + return; + } + + // when we are not at the last one, we can simply hover the next + collection.hovered_request = Some((WhichSlab::RootRequests, key + 1)); + } + }, + } + }) +} + +pub fn hover_prev() { + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + if let Some((which, key)) = collection.hovered_request { + match which { + WhichSlab::Requests => { + let req = collection.requests.get(key); + let parent_key = req.parent.expect("nested request has no parent"); + let parent = collection.folders.get(parent_key); + let pos = parent + .requests + .iter() + .position(|&req| req == key) + .expect("nested request not listed on the parent"); + + // when the current nested request is not the first on the folder, we hover + // the previous request on that folder + if pos > 0 { + collection.hovered_request = Some((WhichSlab::Requests, parent.requests[pos - 1])); + return; + } + + // when it is the first one, we move to the parent folder itself + collection.hovered_request = Some((WhichSlab::Folders, parent_key)) + } + WhichSlab::Folders => { + // when we are hovering a folder, we try to hover the previous folders's last + // request if it exists and the folder is not collapsed, otherwise we select + // the previous folder itself + + // TODO: have a method that instead tries to find the previous key rather than + // doing index math, as indexes are unrealiable + if key > 0 { + let Some(folder) = collection.folders.try_get(key - 1) else { + return; + }; + + if !folder.collapsed { + if let Some(last) = folder.requests.last() { + collection.hovered_request = Some((WhichSlab::Requests, *last)); + return; + }; + } + + collection.hovered_request = Some((WhichSlab::Folders, key - 1)); + return; + } + + // when there are no previous folders we try to hover the last root request + if !collection.root_requests.is_empty() { + let key = collection.root_requests.len() - 1; + collection.hovered_request = Some((WhichSlab::RootRequests, key)); + return; + } + + collection.hovered_request = None; + } + WhichSlab::RootRequests => { + if key > 0 { + collection.hovered_request = Some((WhichSlab::RootRequests, key - 1)); + } + if collection.root_requests.is_empty() { + collection.hovered_request = None; + } + } + } + } + }) +} + +pub fn set_selected_request(new_hovered: Option<(WhichSlab, Key)>) { + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + collection.selected_request = new_hovered; + }) +} + +pub fn set_hovered_request(new_hovered: Option<(WhichSlab, Key)>) { + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + collection.hovered_request = new_hovered; + }) +} + +pub fn get_hovered_request(f: F) -> R +where + F: FnOnce(Option<(WhichSlab, Key)>) -> R, +{ + HAC_STORE.with_borrow(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_ref().unwrap(); + f(collection.hovered_request) + }) +} + +pub fn select_request((which, key): (WhichSlab, Key)) { + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + collection.selected_request = Some((which, key)); + }); +} + +pub fn toggle_dir(key: Key) { + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + let folder = collection.folders.get_mut(key); + folder.collapsed = !folder.collapsed; + }); +} + +pub fn push_request(request: Request, parent: Option) -> Key { + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + match parent { + Some(key) => { + let new_key = collection.requests.push(request); + let folder = collection.folders.get_mut(key); + folder.requests.push(new_key); + new_key + } + None => collection.root_requests.push(request), + } + }) +} + +pub fn rebuild_tree_layout() { + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + let mut nodes: Vec = (0..collection.root_requests.len()).map(ReqTreeNode::Req).collect(); + nodes.extend( + collection + .folders + .enumerated_iter() + .map(|(idx, folder)| ReqTreeNode::Folder(idx, folder.requests.clone())) + .collect::>(), + ); + collection.layout = ReqTree { nodes }; + }) +} + +pub fn borrow_selected_request() -> EntryRef { + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + let Some((slab, key)) = collection.selected_request else { + panic!("trying to borrow a request without having one?"); + }; + let request_ref = match slab { + WhichSlab::Requests => collection.requests.borrow(key, slab), + WhichSlab::RootRequests => collection.root_requests.borrow(key, slab), + _ => unreachable!(), + }; + request_ref + }) +} + +pub fn restore_request(request_ref: EntryRef) { + HAC_STORE.with_borrow_mut(|store| { + assert!(store.collection.is_some()); + let collection = store.collection.as_mut().unwrap(); + + match request_ref.id() { + WhichSlab::Requests => collection.requests.restore(request_ref), + WhichSlab::RootRequests => collection.root_requests.restore(request_ref), + _ => unreachable!(), + }; + }) +} diff --git a/hac-store/src/collection_meta.rs b/hac-store/src/collection_meta.rs new file mode 100644 index 0000000..b827197 --- /dev/null +++ b/hac-store/src/collection_meta.rs @@ -0,0 +1,220 @@ +use std::path::PathBuf; +use std::time::SystemTime; + +use chrono::{Datelike, Timelike}; + +use crate::HAC_STORE; + +// TODO: i probably want to introduce some metadata storage, or caching to know +// total requests, total saved responses, and other infos without having to read +// the entire collection. +#[derive(Debug, Clone)] +pub struct CollectionMeta { + name: String, + path: std::path::PathBuf, + size: u64, + modified: CollectionModifiedMeta, +} + +impl CollectionMeta { + pub fn new(name: String, path: std::path::PathBuf, size: u64, modified: CollectionModifiedMeta) -> Self { + Self { + name, + path, + size, + modified, + } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn name_mut(&mut self) -> &mut String { + &mut self.name + } + + pub fn path(&self) -> &PathBuf { + &self.path + } + + pub fn path_mut(&mut self) -> &mut PathBuf { + &mut self.path + } + + pub fn modified(&self) -> &CollectionModifiedMeta { + &self.modified + } + + pub fn size(&self) -> u64 { + self.size + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CollectionModifiedMeta { + year: i32, + month: u32, + day: u32, + hour: u32, + minutes: u32, + system_time: SystemTime, +} + +impl CollectionModifiedMeta { + pub fn new() -> Self { + std::time::SystemTime::now().into() + } +} + +impl Default for CollectionModifiedMeta { + fn default() -> Self { + Self::new() + } +} + +impl PartialOrd for CollectionModifiedMeta { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.system_time.cmp(&other.system_time)) + } +} + +impl Ord for CollectionModifiedMeta { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.system_time.cmp(&other.system_time) + } +} + +impl std::fmt::Display for CollectionModifiedMeta { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}-{}-{} {}:{}", + self.year, self.month, self.day, self.hour, self.minutes, + ) + } +} + +pub trait ReadableByteSize { + fn readable_byte_size(&self) -> String; +} + +fn readable_byte_size(val: N) -> String +where + N: Copy + Into, +{ + let units = ["B", "KB", "MB", "GB", "TB", "PB"]; + let mut size = val.into() as f64; + let mut unit_index = 0; + + while size >= 1024.0 && unit_index < units.len() - 1 { + size /= 1024.0; + unit_index += 1; + } + + format!("{:.2}{}", size, units[unit_index]) +} + +impl ReadableByteSize for u64 { + fn readable_byte_size(&self) -> String { + readable_byte_size(*self) + } +} + +impl From for CollectionModifiedMeta { + fn from(value: SystemTime) -> Self { + let datetime = chrono::DateTime::::from(value); + let year = datetime.year(); + let month = datetime.day(); + let day = datetime.month(); + let hour = datetime.hour(); + let minutes = datetime.minute(); + + CollectionModifiedMeta { + year, + month, + day, + hour, + minutes, + system_time: value, + } + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum CollectionMetaSorting { + #[default] + Recent, + Name, + Size, +} + +impl CollectionMetaSorting { + pub fn next(&self) -> Self { + match self { + CollectionMetaSorting::Recent => CollectionMetaSorting::Name, + CollectionMetaSorting::Name => CollectionMetaSorting::Size, + CollectionMetaSorting::Size => CollectionMetaSorting::Recent, + } + } + + pub fn prev(&self) -> Self { + match self { + CollectionMetaSorting::Recent => CollectionMetaSorting::Size, + CollectionMetaSorting::Name => CollectionMetaSorting::Recent, + CollectionMetaSorting::Size => CollectionMetaSorting::Name, + } + } +} + +pub fn set_collections_meta(collections_meta: Vec) { + HAC_STORE.with_borrow_mut(|store| store.collections_meta = collections_meta); +} + +pub fn sort_collection_meta(sorting_kind: CollectionMetaSorting) { + HAC_STORE.with_borrow_mut(|store| match sorting_kind { + CollectionMetaSorting::Name => store.collections_meta.sort_by(|a, b| a.name().cmp(b.name())), + CollectionMetaSorting::Recent => store.collections_meta.sort_by(|a, b| b.modified().cmp(a.modified())), + CollectionMetaSorting::Size => store.collections_meta.sort_by_key(|a| std::cmp::Reverse(a.size())), + }) +} + +pub fn collections_meta(f: F) -> R +where + F: FnOnce(&[CollectionMeta]) -> R, +{ + HAC_STORE.with_borrow(|store| f(&store.collections_meta)) +} + +pub fn get_collection_meta(idx: usize, f: F) -> R +where + F: FnOnce(&CollectionMeta) -> R, +{ + HAC_STORE.with_borrow(|store| f(&store.collections_meta[idx])) +} + +pub fn get_collection_meta_mut(idx: usize, f: F) -> R +where + F: FnOnce(&mut CollectionMeta) -> R, +{ + HAC_STORE.with_borrow_mut(|store| f(&mut store.collections_meta[idx])) +} + +pub fn push_collection_meta(collection_meta: CollectionMeta) { + HAC_STORE.with_borrow_mut(|store| store.collections_meta.push(collection_meta)) +} + +pub fn remove_collection_meta(file_name: String, f: F) -> R +where + F: FnOnce(CollectionMeta) -> R, +{ + HAC_STORE.with_borrow_mut(|store| { + let index = store + .collections_meta + .iter() + .position(|meta| !meta.path().to_string_lossy().contains(&file_name)) + .expect("no collection metadata with file name"); + let collection_meta = store.collections_meta.remove(index); + f(collection_meta) + }) +} diff --git a/hac-store/src/lib.rs b/hac-store/src/lib.rs new file mode 100644 index 0000000..a08e09b --- /dev/null +++ b/hac-store/src/lib.rs @@ -0,0 +1,34 @@ +use std::cell::RefCell; + +use collection::Collection; + +pub mod collection; +pub mod collection_meta; +pub mod slab; + +use collection_meta::CollectionMeta; + +thread_local! { + pub(crate) static HAC_STORE: RefCell = const { RefCell::new(HacStore::new()) }; +} + +#[derive(Debug)] +pub struct HacStore { + collection: Option, + collections_meta: Vec, +} + +impl HacStore { + pub const fn new() -> Self { + Self { + collection: None, + collections_meta: vec![], + } + } +} + +impl Default for HacStore { + fn default() -> Self { + Self::new() + } +} diff --git a/hac-store/src/slab.rs b/hac-store/src/slab.rs new file mode 100644 index 0000000..8bc0bf5 --- /dev/null +++ b/hac-store/src/slab.rs @@ -0,0 +1,230 @@ +use crate::collection::WhichSlab; +use std::sync::Arc; +pub type Key = usize; + +#[derive(Debug)] +pub enum Entry { + Full(T), + Free(Option), + Borrowed(Arc), +} + +pub struct EntryRef { + pub inner: Arc, + pub key: Key, + id: WhichSlab, +} + +impl EntryRef { + pub fn id(&self) -> WhichSlab { + self.id + } +} + +impl Entry { + pub fn swap(&mut self, val: T) { + debug_assert!(matches!(self, Entry::Free(_))); + std::mem::swap(self, &mut Self::full(val)) + } + + pub fn free(idx: Option) -> Self { + Self::Free(idx) + } + + pub fn full(val: T) -> Self { + Self::Full(val) + } +} + +#[derive(Debug, Default)] +pub struct Slab { + inner: Vec>, + next_idx: Option, +} + +impl Slab { + pub const fn new() -> Self { + Self { + inner: vec![], + next_idx: None, + } + } + + pub fn push(&mut self, val: T) -> Key { + match self.next_idx.take() { + Some(idx) => { + let entry = &mut self.inner[idx]; + + let Entry::Free(next_idx) = entry else { + panic!("attempt to insert into a full slot."); + }; + + self.next_idx = next_idx.take(); + entry.swap(val); + idx + } + None => { + self.inner.push(Entry::full(val)); + self.inner.len() - 1 + } + } + } + + pub fn prev_key(&self, key: Key) -> Key { + assert!(key == 0, "cant call prev key on first key"); + tracing::error!("{key}"); + + for i in 0..key - 1 { + if let Entry::Full(_) = &self.inner[i] { + return i; + } + } + key + } + + pub fn next_key(&self, key: Key) -> Key { + assert!(key < self.inner.len() - 1, "cant call next key on last key"); + for i in (key + 1)..self.inner.len() { + if let Entry::Full(_) = &self.inner[i] { + return i; + } + } + key + } + + pub fn restore(&mut self, entry_ref: EntryRef) { + let key = entry_ref.key; + let mut entry = Entry::free(None); + std::mem::swap(&mut self.inner[key], &mut entry); + drop(entry_ref); + + let Entry::Borrowed(entry) = entry else { + panic!("attempting to restore a non-borrowed value"); + }; + + let Ok(entry) = Arc::try_unwrap(entry) else { + panic!("restoring reference with more than one borrower"); + }; + + let mut entry = Entry::full(entry); + std::mem::swap(&mut self.inner[key], &mut entry); + } + + pub fn borrow(&mut self, key: Key, id: WhichSlab) -> EntryRef { + let mut entry = Entry::free(None); + std::mem::swap(&mut self.inner[key], &mut entry); + + let val = match entry { + Entry::Full(val) => val, + Entry::Free(_) | Entry::Borrowed(_) => panic!("cannot borrow a free or borrowed entry"), + }; + + let inner = Arc::new(val); + let mut entry = Entry::Borrowed(inner.clone()); + std::mem::swap(&mut self.inner[key], &mut entry); + EntryRef { inner, key, id } + } + + pub fn remove(&mut self, idx: Key) -> T { + let mut entry = Entry::free(self.next_idx.take()); + self.next_idx = Some(idx); + std::mem::swap(&mut self.inner[idx], &mut entry); + + match entry { + Entry::Full(val) => val, + Entry::Free(_) | Entry::Borrowed(_) => panic!("cannot remove a free or borrowed entry"), + } + } + + pub fn get(&self, idx: Key) -> &T { + match &self.inner[idx] { + Entry::Full(val) => val, + Entry::Borrowed(val) => val, + _ => panic!("attempted to get an empty entry"), + } + } + + pub fn get_mut(&mut self, idx: Key) -> &mut T { + let Entry::Full(val) = &mut self.inner[idx] else { + panic!("attempted to get an empty entry"); + }; + val + } + + pub fn try_get(&self, idx: Key) -> Option<&T> { + if let Some(entry) = self.inner.get(idx) { + let Entry::Full(val) = entry else { return None }; + Some(val) + } else { + None + } + } + + pub fn try_get_mut(&mut self, idx: Key) -> Option<&mut T> { + if let Some(entry) = self.inner.get_mut(idx) { + let Entry::Full(val) = entry else { return None }; + Some(val) + } else { + None + } + } + + pub fn len(&self) -> usize { + self.inner + .iter() + .filter_map(|e| match e { + Entry::Full(_) => Some(()), + Entry::Borrowed(_) => Some(()), + Entry::Free(_) => None, + }) + .count() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub fn next_idx(&self) -> Key { + match self.next_idx { + Some(idx) => idx, + None => self.len(), + } + } + + pub fn enumerated_iter(&self) -> impl Iterator { + self.inner.iter().enumerate().filter_map(|(i, e)| match e { + Entry::Full(val) => Some((i, val)), + Entry::Free(_) | Entry::Borrowed(_) => None, + }) + } + + pub fn iter(&self) -> impl Iterator { + self.inner.iter().filter_map(|e| match e { + Entry::Full(val) => Some(val), + Entry::Free(_) | Entry::Borrowed(_) => None, + }) + } + + pub fn iter_mut(&mut self) -> impl Iterator { + self.inner.iter_mut().filter_map(|e| match e { + Entry::Full(val) => Some(val), + Entry::Free(_) | Entry::Borrowed(_) => None, + }) + } +} + +impl IntoIterator for Slab { + type IntoIter = std::vec::IntoIter; + type Item = T; + + fn into_iter(self) -> Self::IntoIter { + self.inner + .into_iter() + .filter_map(|e| match e { + Entry::Full(val) => Some(val), + Entry::Free(_) | Entry::Borrowed(_) => None, + }) + .collect::>() + .into_iter() + } +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..64f0d34 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,10 @@ +edition = "2021" +unstable_features = true +use_field_init_shorthand = true +imports_granularity = "Module" +group_imports = "StdExternalCrate" +format_code_in_doc_comments = true +reorder_impl_items = true +single_line_let_else_max_width = 100 +single_line_if_else_max_width = 100 +max_width = 120