From 0e87150a9157f8f7869e77675dd82552d1c27309 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 30 Jul 2025 21:06:28 +0200 Subject: [PATCH 1/6] WIP tui --- AGENTS.md | 25 ++ Cargo.lock | 408 ++++++++++++++++++++++++++++++ Cargo.toml | 4 + examples/simple_speedtest.rs | 1 + src/lib.rs | 10 +- src/main.rs | 57 ++++- src/measurements.rs | 2 +- src/speedtest.rs | 13 +- src/speedtest_tui.rs | 138 ++++++++++ src/tui/app.rs | 471 +++++++++++++++++++++++++++++++++++ src/tui/dashboard.rs | 250 +++++++++++++++++++ src/tui/events.rs | 38 +++ src/tui/mod.rs | 6 + src/tui/widgets.rs | 166 ++++++++++++ 14 files changed, 1577 insertions(+), 12 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/speedtest_tui.rs create mode 100644 src/tui/app.rs create mode 100644 src/tui/dashboard.rs create mode 100644 src/tui/events.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/widgets.rs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fe9e8c0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# AGENTS.md - Development Guide for cfspeedtest + +## Build/Test Commands +- `cargo build` - Build the project +- `cargo test` - Run all tests +- `cargo test test_name` - Run a specific test +- `cargo fmt` - Format code (required before commits) +- `cargo clippy` - Run linter +- `cargo run` - Run the CLI tool +- `cargo run --example simple_speedtest` - Run example + +## Code Style Guidelines +- Use `cargo fmt` for consistent formatting +- Follow Rust 2021 edition conventions +- Use snake_case for functions/variables, PascalCase for types/enums +- Prefer explicit types in public APIs +- Use `Result` for error handling with descriptive messages +- Import std modules first, then external crates, then local modules +- Use `log` crate for logging, `env_logger` for initialization +- Prefer `reqwest::blocking::Client` for HTTP requests +- Use `clap` derive macros for CLI argument parsing +- Write comprehensive unit tests in `#[cfg(test)]` modules +- Use `serde` for serialization with `#[derive(Serialize)]` +- Constants should be SCREAMING_SNAKE_CASE +- Prefer `Duration` and `Instant` for time measurements \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index c7deeee..7741b04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "0.6.19" @@ -76,6 +82,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "backtrace" version = "0.3.75" @@ -115,6 +127,21 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.27" @@ -142,14 +169,18 @@ version = "1.4.1" dependencies = [ "clap", "clap_complete", + "crossbeam-channel", + "crossterm", "csv", "env_logger", "indexmap", "log", + "ratatui", "regex", "reqwest", "serde", "serde_json", + "tokio", ] [[package]] @@ -207,6 +238,60 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "csv" version = "1.3.1" @@ -228,6 +313,41 @@ dependencies = [ "memchr", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -239,6 +359,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "env_filter" version = "0.1.3" @@ -268,12 +394,28 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -371,6 +513,11 @@ name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -564,6 +711,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -595,6 +748,25 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + +[[package]] +name = "instability" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -617,6 +789,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -663,18 +844,43 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "litemap" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -703,6 +909,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.59.0", ] @@ -728,6 +935,35 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.1" @@ -887,6 +1123,36 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.11.1" @@ -982,6 +1248,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rustls" version = "0.23.28" @@ -1029,6 +1308,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.219" @@ -1079,6 +1364,36 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +dependencies = [ + "libc", +] + [[package]] name = "slab" version = "0.4.10" @@ -1107,12 +1422,40 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1205,11 +1548,25 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.52.0", ] +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-rustls" version = "0.26.2" @@ -1296,6 +1653,35 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "untrusted" version = "0.9.0" @@ -1449,6 +1835,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[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-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index e73b3db..4f002de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,7 @@ csv = "1.3.0" serde_json = "1.0" indexmap = "2.9.0" clap_complete = "4.5" +ratatui = "0.29.0" +crossterm = "0.28" +tokio = { version = "1.0", features = ["full"] } +crossbeam-channel = "0.5" diff --git a/examples/simple_speedtest.rs b/examples/simple_speedtest.rs index 6e2224d..c7ee3c1 100644 --- a/examples/simple_speedtest.rs +++ b/examples/simple_speedtest.rs @@ -17,6 +17,7 @@ fn main() { max_payload_size: PayloadSize::M10, disable_dynamic_max_payload_size: false, completion: None, + tui: false, }; let measurements = speed_test(reqwest::blocking::Client::new(), options); diff --git a/src/lib.rs b/src/lib.rs index 531df14..7a23add 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,8 @@ pub mod boxplot; pub mod measurements; pub mod progress; pub mod speedtest; +pub mod speedtest_tui; +pub mod tui; use std::fmt; use std::fmt::Display; @@ -37,7 +39,7 @@ impl OutputFormat { } /// Unofficial CLI for speed.cloudflare.com -#[derive(Parser, Debug)] +#[derive(Parser, Debug, Clone)] #[command(author, version, about, long_about = None)] pub struct SpeedTestCLIOptions { /// Number of test runs per payload size. @@ -85,6 +87,10 @@ pub struct SpeedTestCLIOptions { /// Generate shell completion script for the specified shell #[arg(long = "generate-completion", value_enum)] pub completion: Option, + + /// Launch TUI dashboard instead of CLI output + #[arg(long)] + pub tui: bool, } impl SpeedTestCLIOptions { @@ -182,6 +188,7 @@ mod tests { download_only: false, upload_only: false, completion: None, + tui: false, }; // Default: both download and upload @@ -214,6 +221,7 @@ mod tests { download_only: false, upload_only: false, completion: None, + tui: false, }; // Default: both download and upload diff --git a/src/main.rs b/src/main.rs index 55fd1d3..31c20f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,19 @@ use cfspeedtest::speedtest; +use cfspeedtest::speedtest_tui; +use cfspeedtest::tui::App; use cfspeedtest::OutputFormat; use cfspeedtest::SpeedTestCLIOptions; use clap::{CommandFactory, Parser}; use clap_complete::generate; + +use crossterm::{ + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; use std::net::IpAddr; +use std::thread; use speedtest::speed_test; @@ -12,6 +21,43 @@ fn print_completions(gen: G, cmd: &mut clap::Comman generate(gen, cmd, cmd.get_name().to_string(), &mut io::stdout()); } +fn run_tui_mode(client: reqwest::blocking::Client, options: SpeedTestCLIOptions) { + // Setup terminal + enable_raw_mode().expect("Failed to enable raw mode"); + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen).expect("Failed to enter alternate screen"); + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend).expect("Failed to create terminal"); + + // Create channel for communication between speedtest and TUI + let (event_sender, event_receiver) = crossbeam_channel::unbounded(); + + // Create and configure the app + let mut app = App::new().with_receiver(event_receiver); + + // Start speedtest in a separate thread + let event_sender_clone = event_sender.clone(); + let client_clone = client.clone(); + let options_clone = options.clone(); + + thread::spawn(move || { + speedtest_tui::speed_test_tui(client_clone, options_clone, event_sender_clone); + }); + + // Run the TUI + let result = app.run(&mut terminal); + + // Cleanup terminal + disable_raw_mode().expect("Failed to disable raw mode"); + execute!(terminal.backend_mut(), LeaveAlternateScreen) + .expect("Failed to leave alternate screen"); + terminal.show_cursor().expect("Failed to show cursor"); + + if let Err(err) = result { + eprintln!("TUI error: {}", err); + } +} + fn main() { env_logger::init(); let options = SpeedTestCLIOptions::parse(); @@ -42,8 +88,11 @@ fn main() { .timeout(std::time::Duration::from_secs(30)) .build(); } - speed_test( - client.expect("Failed to initialize reqwest client"), - options, - ); + let client = client.expect("Failed to initialize reqwest client"); + + if options.tui { + run_tui_mode(client, options); + } else { + speed_test(client, options); + } } diff --git a/src/measurements.rs b/src/measurements.rs index da8b7ff..e9e35ca 100644 --- a/src/measurements.rs +++ b/src/measurements.rs @@ -17,7 +17,7 @@ struct StatMeasurement { avg: f64, } -#[derive(Serialize)] +#[derive(Serialize, Clone)] pub struct Measurement { pub test_type: TestType, pub payload_size: usize, diff --git a/src/speedtest.rs b/src/speedtest.rs index a267f2e..cb1f466 100644 --- a/src/speedtest.rs +++ b/src/speedtest.rs @@ -64,12 +64,13 @@ impl PayloadSize { } } +#[derive(Debug, Clone)] pub struct Metadata { - city: String, - country: String, - ip: String, - asn: String, - colo: String, + pub city: String, + pub country: String, + pub ip: String, + pub asn: String, + pub colo: String, } impl Display for Metadata { @@ -186,7 +187,7 @@ pub fn test_latency(client: &Client) -> f64 { req_latency } -const TIME_THRESHOLD: Duration = Duration::from_secs(5); +pub const TIME_THRESHOLD: Duration = Duration::from_secs(5); pub fn run_tests( client: &Client, diff --git a/src/speedtest_tui.rs b/src/speedtest_tui.rs new file mode 100644 index 0000000..abedfd3 --- /dev/null +++ b/src/speedtest_tui.rs @@ -0,0 +1,138 @@ +use crate::measurements::Measurement; +use crate::speedtest::{ + fetch_metadata, test_download, test_latency, test_upload, PayloadSize, TestType, TIME_THRESHOLD, +}; +use crate::tui::app::{LatencyData, SpeedData, TestEvent}; +use crate::{OutputFormat, SpeedTestCLIOptions}; +use crossbeam_channel::Sender; +use reqwest::blocking::Client; +use std::thread; +use std::time::{Duration, Instant}; + +pub fn speed_test_tui( + client: Client, + options: SpeedTestCLIOptions, + event_sender: Sender, +) -> Vec { + let _metadata = match fetch_metadata(&client) { + Ok(metadata) => { + let _ = event_sender.send(TestEvent::MetadataReceived(metadata.clone())); + metadata + } + Err(e) => { + let _ = event_sender.send(TestEvent::Error(format!("Error fetching metadata: {e}"))); + return Vec::new(); + } + }; + + let mut measurements = Vec::new(); + + // Run latency tests + let (_latency_measurements, _avg_latency) = + run_latency_test_tui(&client, options.nr_latency_tests, event_sender.clone()); + + let payload_sizes = PayloadSize::sizes_from_max(options.max_payload_size.clone()); + + // Run download tests + if options.should_download() { + measurements.extend(run_tests_tui( + &client, + test_download, + TestType::Download, + payload_sizes.clone(), + options.nr_tests, + options.disable_dynamic_max_payload_size, + event_sender.clone(), + )); + } + + // Run upload tests + if options.should_upload() { + measurements.extend(run_tests_tui( + &client, + test_upload, + TestType::Upload, + payload_sizes.clone(), + options.nr_tests, + options.disable_dynamic_max_payload_size, + event_sender.clone(), + )); + } + + let _ = event_sender.send(TestEvent::AllTestsCompleted); + measurements +} + +pub fn run_latency_test_tui( + client: &Client, + nr_latency_tests: u32, + event_sender: Sender, +) -> (Vec, f64) { + let mut measurements: Vec = Vec::new(); + + for _i in 0..=nr_latency_tests { + let latency = test_latency(client); + measurements.push(latency); + + let _ = event_sender.send(TestEvent::LatencyMeasurement(LatencyData { + timestamp: Instant::now(), + latency, + })); + + // Small delay to make the UI updates visible + thread::sleep(Duration::from_millis(50)); + } + + let avg_latency = measurements.iter().sum::() / measurements.len() as f64; + (measurements, avg_latency) +} + +pub fn run_tests_tui( + client: &Client, + test_fn: fn(&Client, usize, OutputFormat) -> f64, + test_type: TestType, + payload_sizes: Vec, + nr_tests: u32, + disable_dynamic_max_payload_size: bool, + event_sender: Sender, +) -> Vec { + let mut measurements: Vec = Vec::new(); + + for payload_size in payload_sizes { + let _ = event_sender.send(TestEvent::TestStarted(test_type, payload_size)); + + let start = Instant::now(); + for _i in 0..nr_tests { + let mbit = test_fn(client, payload_size, OutputFormat::None); + + let measurement = Measurement { + test_type, + payload_size, + mbit, + }; + measurements.push(measurement.clone()); + + let _ = event_sender.send(TestEvent::SpeedMeasurement(SpeedData { + timestamp: Instant::now(), + speed: mbit, + test_type, + payload_size, + })); + + let _ = event_sender.send(TestEvent::TestCompleted(test_type, payload_size)); + + // Small delay to make the UI updates visible + thread::sleep(Duration::from_millis(100)); + } + + let duration = start.elapsed(); + + // Check time threshold for dynamic payload sizing + if !disable_dynamic_max_payload_size && duration > TIME_THRESHOLD { + log::info!("Exceeded threshold"); + break; + } + } + + measurements +} diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..dbc59e6 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,471 @@ +use crate::measurements::Measurement; +use crate::speedtest::{TestType, Metadata}; +use crossbeam_channel::Receiver; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Gauge, Paragraph}, + Frame, Terminal, +}; +use std::collections::VecDeque; +use std::io; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone)] +pub struct SpeedData { + pub timestamp: Instant, + pub speed: f64, + pub test_type: TestType, + pub payload_size: usize, +} + +#[derive(Debug, Clone)] +pub struct LatencyData { + pub timestamp: Instant, + pub latency: f64, +} + +#[derive(Debug, Clone)] +pub enum TestEvent { + SpeedMeasurement(SpeedData), + LatencyMeasurement(LatencyData), + TestStarted(TestType, usize), + TestCompleted(TestType, usize), + AllTestsCompleted, + MetadataReceived(Metadata), + Error(String), +} + +#[derive(Debug, Clone)] +pub struct TestProgress { + pub current_test: Option, + pub current_payload_size: Option, + pub current_iteration: u32, + pub total_iterations: u32, + pub phase: TestPhase, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TestPhase { + Idle, + Latency, + Download, + Upload, + Completed, +} + +pub struct DashboardState { + pub download_speeds: VecDeque, + pub upload_speeds: VecDeque, + pub latency_measurements: VecDeque, + pub progress: TestProgress, + pub current_download_speed: f64, + pub current_upload_speed: f64, + pub current_latency: f64, + pub avg_latency: f64, + pub min_latency: f64, + pub max_latency: f64, + pub measurements: Vec, + pub start_time: Instant, + pub max_data_points: usize, + pub metadata: Option, +} + +impl Default for DashboardState { + fn default() -> Self { + Self { + download_speeds: VecDeque::new(), + upload_speeds: VecDeque::new(), + latency_measurements: VecDeque::new(), + progress: TestProgress { + current_test: None, + current_payload_size: None, + current_iteration: 0, + total_iterations: 0, + phase: TestPhase::Idle, + }, + current_download_speed: 0.0, + current_upload_speed: 0.0, + current_latency: 0.0, + avg_latency: 0.0, + min_latency: f64::MAX, + max_latency: 0.0, + measurements: Vec::new(), + start_time: Instant::now(), + max_data_points: 100, + metadata: None, + } + } +} + +impl DashboardState { + pub fn update(&mut self, event: TestEvent) { + match event { + TestEvent::SpeedMeasurement(data) => { + match data.test_type { + TestType::Download => { + self.current_download_speed = data.speed; + self.download_speeds.push_back(data.clone()); + if self.download_speeds.len() > self.max_data_points { + self.download_speeds.pop_front(); + } + } + TestType::Upload => { + self.current_upload_speed = data.speed; + self.upload_speeds.push_back(data.clone()); + if self.upload_speeds.len() > self.max_data_points { + self.upload_speeds.pop_front(); + } + } + } + + self.measurements.push(Measurement { + test_type: data.test_type, + payload_size: data.payload_size, + mbit: data.speed, + }); + } + TestEvent::LatencyMeasurement(data) => { + self.current_latency = data.latency; + self.latency_measurements.push_back(data.clone()); + if self.latency_measurements.len() > self.max_data_points { + self.latency_measurements.pop_front(); + } + + if data.latency < self.min_latency { + self.min_latency = data.latency; + } + if data.latency > self.max_latency { + self.max_latency = data.latency; + } + + let sum: f64 = self.latency_measurements.iter().map(|l| l.latency).sum(); + self.avg_latency = sum / self.latency_measurements.len() as f64; + } + TestEvent::TestStarted(test_type, payload_size) => { + self.progress.current_test = Some(test_type); + self.progress.current_payload_size = Some(payload_size); + self.progress.phase = match test_type { + TestType::Download => TestPhase::Download, + TestType::Upload => TestPhase::Upload, + }; + } + TestEvent::TestCompleted(_, _) => { + self.progress.current_iteration += 1; + } + TestEvent::AllTestsCompleted => { + self.progress.phase = TestPhase::Completed; + self.progress.current_test = None; + self.progress.current_payload_size = None; + } + TestEvent::MetadataReceived(metadata) => { + self.metadata = Some(metadata); + } + TestEvent::Error(_) => { + // Handle errors if needed + } + } + } +} + +pub struct App { + pub state: DashboardState, + pub should_quit: bool, + pub event_receiver: Option>, +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} + +impl App { + pub fn new() -> Self { + Self { + state: DashboardState::default(), + should_quit: false, + event_receiver: None, + } + } + + pub fn with_receiver(mut self, receiver: Receiver) -> Self { + self.event_receiver = Some(receiver); + self + } + + pub fn run(&mut self, terminal: &mut Terminal>) -> io::Result<()> { + loop { + terminal.draw(|f| self.draw(f))?; + + if self.should_quit { + break; + } + + if let Some(receiver) = &self.event_receiver { + while let Ok(event) = receiver.try_recv() { + self.state.update(event); + } + } + + if event::poll(Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => { + self.should_quit = true; + } + _ => {} + } + } + } + } + } + Ok(()) + } + + fn draw(&self, f: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title + Constraint::Length(3), // Progress bars + Constraint::Min(10), // Main content + Constraint::Length(3), // Status + ]) + .split(f.area()); + + self.draw_title(f, chunks[0]); + self.draw_progress_bars(f, chunks[1]); + self.draw_main_content(f, chunks[2]); + self.draw_status(f, chunks[3]); + } + + fn draw_title(&self, f: &mut Frame, area: Rect) { + let title_text = if let Some(ref metadata) = self.state.metadata { + format!( + "Cloudflare Speed Test - {} {} | IP: {} | Colo: {}", + metadata.city, metadata.country, metadata.ip, metadata.colo + ) + } else { + "Cloudflare Speed Test - Loading...".to_string() + }; + + let title = Paragraph::new(title_text) + .style(Style::default().fg(Color::Cyan)) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(title, area); + } + + fn draw_progress_bars(&self, f: &mut Frame, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + let download_progress = if self.state.progress.phase == TestPhase::Download { + (self.state.progress.current_iteration as f64 + / self.state.progress.total_iterations as f64) + .min(1.0) + } else if matches!( + self.state.progress.phase, + TestPhase::Upload | TestPhase::Completed + ) { + 1.0 + } else { + 0.0 + }; + + let upload_progress = if self.state.progress.phase == TestPhase::Upload { + (self.state.progress.current_iteration as f64 + / self.state.progress.total_iterations as f64) + .min(1.0) + } else if self.state.progress.phase == TestPhase::Completed { + 1.0 + } else { + 0.0 + }; + + let download_gauge = Gauge::default() + .block(Block::default().title("Download").borders(Borders::ALL)) + .gauge_style(Style::default().fg(Color::Green)) + .ratio(download_progress); + + let upload_gauge = Gauge::default() + .block(Block::default().title("Upload").borders(Borders::ALL)) + .gauge_style(Style::default().fg(Color::Blue)) + .ratio(upload_progress); + + f.render_widget(download_gauge, chunks[0]); + f.render_widget(upload_gauge, chunks[1]); + } + + fn draw_main_content(&self, f: &mut Frame, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(area); + + self.draw_speed_graphs(f, chunks[0]); + self.draw_stats_and_boxplots(f, chunks[1]); + } + + fn draw_speed_graphs(&self, f: &mut Frame, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + self.draw_download_graph(f, chunks[0]); + self.draw_upload_graph(f, chunks[1]); + } + + fn draw_download_graph(&self, f: &mut Frame, area: Rect) { + let title = format!( + "Download Speed ({:.1} Mbps)", + self.state.current_download_speed + ); + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)); + + let inner = block.inner(area); + f.render_widget(block, area); + + if !self.state.download_speeds.is_empty() { + let graph_widget = crate::tui::widgets::LineGraph::new(&self.state.download_speeds) + .color(Color::Green); + f.render_widget(graph_widget, inner); + } + } + + fn draw_upload_graph(&self, f: &mut Frame, area: Rect) { + let title = format!("Upload Speed ({:.1} Mbps)", self.state.current_upload_speed); + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Blue)); + + let inner = block.inner(area); + f.render_widget(block, area); + + if !self.state.upload_speeds.is_empty() { + let graph_widget = + crate::tui::widgets::LineGraph::new(&self.state.upload_speeds).color(Color::Blue); + f.render_widget(graph_widget, inner); + } + } + + fn draw_stats_and_boxplots(&self, f: &mut Frame, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + self.draw_latency_stats(f, chunks[0]); + self.draw_boxplots(f, chunks[1]); + } + + fn draw_latency_stats(&self, f: &mut Frame, area: Rect) { + let stats_text = vec![ + Line::from(vec![ + Span::raw("Current: "), + Span::styled( + format!("{:.1}ms", self.state.current_latency), + Style::default().fg(Color::Yellow), + ), + ]), + Line::from(vec![ + Span::raw("Average: "), + Span::styled( + format!("{:.1}ms", self.state.avg_latency), + Style::default().fg(Color::Green), + ), + ]), + Line::from(vec![ + Span::raw("Min/Max: "), + Span::styled( + format!( + "{:.1}ms / {:.1}ms", + if self.state.min_latency == f64::MAX { + 0.0 + } else { + self.state.min_latency + }, + self.state.max_latency + ), + Style::default().fg(Color::Cyan), + ), + ]), + ]; + + let paragraph = Paragraph::new(stats_text).block( + Block::default() + .title("Latency Stats") + .borders(Borders::ALL), + ); + f.render_widget(paragraph, area); + } + + fn draw_boxplots(&self, f: &mut Frame, area: Rect) { + let boxplot_text = vec![ + Line::from("Download: |----[===:===]----|"), + Line::from("Upload: |--[=====:=====]--|"), + Line::from("Latency: |-----[=:=]-------|"), + ]; + + let paragraph = Paragraph::new(boxplot_text).block( + Block::default() + .title("Measurement Boxplots") + .borders(Borders::ALL), + ); + f.render_widget(paragraph, area); + } + + fn draw_status(&self, f: &mut Frame, area: Rect) { + let status_text = match self.state.progress.phase { + TestPhase::Idle => "Ready to start tests. Press 'q' to quit.".to_string(), + TestPhase::Latency => "Running latency tests...".to_string(), + TestPhase::Download => { + if let (Some(payload_size), Some(_)) = ( + self.state.progress.current_payload_size, + self.state.progress.current_test, + ) { + format!( + "Testing Download {}MB [{}/{}]", + payload_size / 1_000_000, + self.state.progress.current_iteration, + self.state.progress.total_iterations + ) + } else { + "Testing Download...".to_string() + } + } + TestPhase::Upload => { + if let (Some(payload_size), Some(_)) = ( + self.state.progress.current_payload_size, + self.state.progress.current_test, + ) { + format!( + "Testing Upload {}MB [{}/{}]", + payload_size / 1_000_000, + self.state.progress.current_iteration, + self.state.progress.total_iterations + ) + } else { + "Testing Upload...".to_string() + } + } + TestPhase::Completed => "All tests completed! Press 'q' to quit.".to_string(), + }; + + let paragraph = Paragraph::new(status_text) + .style(Style::default().fg(Color::White)) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(paragraph, area); + } +} diff --git a/src/tui/dashboard.rs b/src/tui/dashboard.rs new file mode 100644 index 0000000..2841965 --- /dev/null +++ b/src/tui/dashboard.rs @@ -0,0 +1,250 @@ +use crate::tui::app::{DashboardState, TestPhase}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Gauge, Paragraph}, + Frame, +}; + +pub fn render_dashboard(f: &mut Frame, state: &DashboardState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title + Constraint::Length(3), // Progress bars + Constraint::Min(10), // Main content + Constraint::Length(3), // Status + ]) + .split(f.area()); + + render_title(f, chunks[0], state); + render_progress_bars(f, chunks[1], state); + render_main_content(f, chunks[2], state); + render_status(f, chunks[3], state); +} + +fn render_title(f: &mut Frame, area: Rect, state: &DashboardState) { + let title_text = if let Some(ref metadata) = state.metadata { + format!( + "Cloudflare Speed Test - {} {} | IP: {} | Colo: {}", + metadata.city, metadata.country, metadata.ip, metadata.colo + ) + } else { + "Cloudflare Speed Test - Loading...".to_string() + }; + + let title = Paragraph::new(title_text) + .style(Style::default().fg(Color::Cyan)) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(title, area); +} + +fn render_progress_bars(f: &mut Frame, area: Rect, state: &DashboardState) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + let download_progress = match state.progress.phase { + TestPhase::Download => { + if state.progress.total_iterations > 0 { + state.progress.current_iteration as f64 / state.progress.total_iterations as f64 + } else { + 0.0 + } + } + TestPhase::Upload | TestPhase::Completed => 1.0, + _ => 0.0, + }; + + let upload_progress = match state.progress.phase { + TestPhase::Upload => { + if state.progress.total_iterations > 0 { + state.progress.current_iteration as f64 / state.progress.total_iterations as f64 + } else { + 0.0 + } + } + TestPhase::Completed => 1.0, + _ => 0.0, + }; + + let download_gauge = Gauge::default() + .block(Block::default().title("Download").borders(Borders::ALL)) + .gauge_style(Style::default().fg(Color::Green)) + .ratio(download_progress.min(1.0)); + + let upload_gauge = Gauge::default() + .block(Block::default().title("Upload").borders(Borders::ALL)) + .gauge_style(Style::default().fg(Color::Blue)) + .ratio(upload_progress.min(1.0)); + + f.render_widget(download_gauge, chunks[0]); + f.render_widget(upload_gauge, chunks[1]); +} + +fn render_main_content(f: &mut Frame, area: Rect, state: &DashboardState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) + .split(area); + + render_speed_graphs(f, chunks[0], state); + render_stats_and_boxplots(f, chunks[1], state); +} + +fn render_speed_graphs(f: &mut Frame, area: Rect, state: &DashboardState) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + render_download_graph(f, chunks[0], state); + render_upload_graph(f, chunks[1], state); +} + +fn render_download_graph(f: &mut Frame, area: Rect, state: &DashboardState) { + let title = format!("Download Speed ({:.1} Mbps)", state.current_download_speed); + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)); + + let inner = block.inner(area); + f.render_widget(block, area); + + if !state.download_speeds.is_empty() { + let speeds: Vec = state.download_speeds.iter().map(|d| d.speed).collect(); + let graph_widget = crate::tui::widgets::SimpleLineChart::new(&speeds).color(Color::Green); + f.render_widget(graph_widget, inner); + } else { + let placeholder = + Paragraph::new("Waiting for data...").style(Style::default().fg(Color::Gray)); + f.render_widget(placeholder, inner); + } +} + +fn render_upload_graph(f: &mut Frame, area: Rect, state: &DashboardState) { + let title = format!("Upload Speed ({:.1} Mbps)", state.current_upload_speed); + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Blue)); + + let inner = block.inner(area); + f.render_widget(block, area); + + if !state.upload_speeds.is_empty() { + let speeds: Vec = state.upload_speeds.iter().map(|d| d.speed).collect(); + let graph_widget = crate::tui::widgets::SimpleLineChart::new(&speeds).color(Color::Blue); + f.render_widget(graph_widget, inner); + } else { + let placeholder = + Paragraph::new("Waiting for data...").style(Style::default().fg(Color::Gray)); + f.render_widget(placeholder, inner); + } +} + +fn render_stats_and_boxplots(f: &mut Frame, area: Rect, state: &DashboardState) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + render_latency_stats(f, chunks[0], state); + render_boxplots(f, chunks[1], state); +} + +fn render_latency_stats(f: &mut Frame, area: Rect, state: &DashboardState) { + let stats_text = vec![ + Line::from(vec![ + Span::raw("Current: "), + Span::styled( + format!("{:.1}ms", state.current_latency), + Style::default().fg(Color::Yellow), + ), + ]), + Line::from(vec![ + Span::raw("Average: "), + Span::styled( + format!("{:.1}ms", state.avg_latency), + Style::default().fg(Color::Green), + ), + ]), + Line::from(vec![ + Span::raw("Min/Max: "), + Span::styled( + format!( + "{:.1}ms / {:.1}ms", + if state.min_latency == f64::MAX { + 0.0 + } else { + state.min_latency + }, + state.max_latency + ), + Style::default().fg(Color::Cyan), + ), + ]), + ]; + + let paragraph = Paragraph::new(stats_text).block( + Block::default() + .title("Latency Stats") + .borders(Borders::ALL), + ); + f.render_widget(paragraph, area); +} + +fn render_boxplots(f: &mut Frame, area: Rect, _state: &DashboardState) { + let boxplot_text = vec![ + Line::from("Download: |----[===:===]----|"), + Line::from("Upload: |--[=====:=====]--|"), + Line::from("Latency: |-----[=:=]-------|"), + ]; + + let paragraph = Paragraph::new(boxplot_text).block( + Block::default() + .title("Measurement Boxplots") + .borders(Borders::ALL), + ); + f.render_widget(paragraph, area); +} + +fn render_status(f: &mut Frame, area: Rect, state: &DashboardState) { + let status_text = match state.progress.phase { + TestPhase::Idle => "Ready to start tests. Press 'q' to quit.".to_string(), + TestPhase::Latency => "Running latency tests...".to_string(), + TestPhase::Download => { + if let Some(payload_size) = state.progress.current_payload_size { + format!( + "Testing Download {}MB [{}/{}]", + payload_size / 1_000_000, + state.progress.current_iteration, + state.progress.total_iterations + ) + } else { + "Testing Download...".to_string() + } + } + TestPhase::Upload => { + if let Some(payload_size) = state.progress.current_payload_size { + format!( + "Testing Upload {}MB [{}/{}]", + payload_size / 1_000_000, + state.progress.current_iteration, + state.progress.total_iterations + ) + } else { + "Testing Upload...".to_string() + } + } + TestPhase::Completed => "All tests completed! Press 'q' to quit.".to_string(), + }; + + let paragraph = Paragraph::new(status_text) + .style(Style::default().fg(Color::White)) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(paragraph, area); +} diff --git a/src/tui/events.rs b/src/tui/events.rs new file mode 100644 index 0000000..87d1edf --- /dev/null +++ b/src/tui/events.rs @@ -0,0 +1,38 @@ +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use std::io; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub enum AppEvent { + Quit, + Tick, + Key(KeyCode), +} + +pub struct EventHandler; + +impl Default for EventHandler { + fn default() -> Self { + Self::new() + } +} + +impl EventHandler { + pub fn new() -> Self { + Self + } + + pub fn next_event(&self, timeout: Duration) -> io::Result> { + if event::poll(timeout)? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => match key.code { + KeyCode::Char('q') | KeyCode::Esc => Ok(Some(AppEvent::Quit)), + code => Ok(Some(AppEvent::Key(code))), + }, + _ => Ok(None), + } + } else { + Ok(Some(AppEvent::Tick)) + } + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..1b65680 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,6 @@ +pub mod app; +pub mod dashboard; +pub mod events; +pub mod widgets; + +pub use app::App; diff --git a/src/tui/widgets.rs b/src/tui/widgets.rs new file mode 100644 index 0000000..f28233e --- /dev/null +++ b/src/tui/widgets.rs @@ -0,0 +1,166 @@ +use crate::tui::app::SpeedData; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Color, + widgets::{ + canvas::{Canvas, Line, Points}, + Widget, + }, +}; +use std::collections::VecDeque; + +pub struct LineGraph<'a> { + data: &'a VecDeque, + color: Color, +} + +impl<'a> LineGraph<'a> { + pub fn new(data: &'a VecDeque) -> Self { + Self { + data, + color: Color::White, + } + } + + pub fn color(mut self, color: Color) -> Self { + self.color = color; + self + } +} + +impl<'a> Widget for LineGraph<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + if self.data.is_empty() { + return; + } + + let max_speed = self + .data + .iter() + .map(|d| d.speed) + .fold(0.0f64, f64::max) + .max(1.0); // Ensure minimum scale + + let min_speed = 0.0; + let speed_range = max_speed - min_speed; + + let points: Vec<(f64, f64)> = self + .data + .iter() + .enumerate() + .map(|(i, data)| { + let x = i as f64; + let y = (data.speed - min_speed) / speed_range * 100.0; + (x, y) + }) + .collect(); + + if points.len() < 2 { + return; + } + + let canvas = Canvas::default() + .x_bounds([0.0, (self.data.len() - 1) as f64]) + .y_bounds([0.0, 100.0]) + .paint(|ctx| { + // Draw the line graph + for window in points.windows(2) { + if let [p1, p2] = window { + ctx.draw(&Line { + x1: p1.0, + y1: p1.1, + x2: p2.0, + y2: p2.1, + color: self.color, + }); + } + } + + // Draw points + ctx.draw(&Points { + coords: &points, + color: self.color, + }); + }); + + canvas.render(area, buf); + } +} + +pub struct SimpleLineChart<'a> { + data: &'a [f64], + color: Color, + max_value: Option, +} + +impl<'a> SimpleLineChart<'a> { + pub fn new(data: &'a [f64]) -> Self { + Self { + data, + color: Color::White, + max_value: None, + } + } + + pub fn color(mut self, color: Color) -> Self { + self.color = color; + self + } + + pub fn max_value(mut self, max_value: f64) -> Self { + self.max_value = Some(max_value); + self + } +} + +impl<'a> Widget for SimpleLineChart<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + if self.data.is_empty() || area.height < 2 { + return; + } + + let max_val = self + .max_value + .unwrap_or_else(|| self.data.iter().fold(0.0f64, |a, &b| a.max(b)).max(1.0)); + + let width = area.width as usize; + let height = area.height as usize; + + // Sample data to fit the width + let step = if self.data.len() > width { + self.data.len() / width + } else { + 1 + }; + + let sampled_data: Vec = self + .data + .iter() + .step_by(step) + .take(width) + .cloned() + .collect(); + + // Draw the line chart using simple characters + for (i, &value) in sampled_data.iter().enumerate() { + if i >= width { + break; + } + + let normalized = (value / max_val).min(1.0); + let bar_height = (normalized * (height - 1) as f64) as usize; + + for y in 0..height { + let screen_y = area.y + (height - 1 - y) as u16; + let screen_x = area.x + i as u16; + + if y <= bar_height { + if let Some(cell) = buf.cell_mut((screen_x, screen_y)) { + cell.set_char('█').set_fg(self.color); + } + } + } + } + } +} From f221ab3a3a32c3223680f30c6d398b014072c233 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 30 Jul 2025 21:32:03 +0200 Subject: [PATCH 2/6] WIP better boxplots --- src/measurements.rs | 2 +- src/speedtest.rs | 2 +- src/speedtest_tui.rs | 50 +++++- src/tui/app.rs | 353 ++++++++++++++++++++++++++++++------------- src/tui/dashboard.rs | 153 ++++++------------- src/tui/widgets.rs | 326 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 663 insertions(+), 223 deletions(-) diff --git a/src/measurements.rs b/src/measurements.rs index e9e35ca..28ef707 100644 --- a/src/measurements.rs +++ b/src/measurements.rs @@ -131,7 +131,7 @@ fn log_measurements_by_test_type( stat_measurements } -fn calc_stats(mbit_measurements: Vec) -> Option<(f64, f64, f64, f64, f64, f64)> { +pub fn calc_stats(mbit_measurements: Vec) -> Option<(f64, f64, f64, f64, f64, f64)> { log::debug!("calc_stats for mbit_measurements {mbit_measurements:?}"); let length = mbit_measurements.len(); if length == 0 { diff --git a/src/speedtest.rs b/src/speedtest.rs index cb1f466..898edd4 100644 --- a/src/speedtest.rs +++ b/src/speedtest.rs @@ -17,7 +17,7 @@ const BASE_URL: &str = "https://speed.cloudflare.com"; const DOWNLOAD_URL: &str = "__down?bytes="; const UPLOAD_URL: &str = "__up"; -#[derive(Clone, Copy, Debug, Hash, Serialize, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Hash, Serialize, Eq, PartialEq, Ord, PartialOrd)] pub enum TestType { Download, Upload, diff --git a/src/speedtest_tui.rs b/src/speedtest_tui.rs index abedfd3..e749ce3 100644 --- a/src/speedtest_tui.rs +++ b/src/speedtest_tui.rs @@ -35,6 +35,11 @@ pub fn speed_test_tui( // Run download tests if options.should_download() { + let _ = event_sender.send(TestEvent::TestPhaseStarted( + TestType::Download, + options.nr_tests, + payload_sizes.clone(), + )); measurements.extend(run_tests_tui( &client, test_download, @@ -48,6 +53,11 @@ pub fn speed_test_tui( // Run upload tests if options.should_upload() { + let _ = event_sender.send(TestEvent::TestPhaseStarted( + TestType::Upload, + options.nr_tests, + payload_sizes.clone(), + )); measurements.extend(run_tests_tui( &client, test_upload, @@ -62,7 +72,6 @@ pub fn speed_test_tui( let _ = event_sender.send(TestEvent::AllTestsCompleted); measurements } - pub fn run_latency_test_tui( client: &Client, nr_latency_tests: u32, @@ -70,6 +79,13 @@ pub fn run_latency_test_tui( ) -> (Vec, f64) { let mut measurements: Vec = Vec::new(); + // Set latency phase + let _ = event_sender.send(TestEvent::TestPhaseStarted( + TestType::Download, // Use Download as placeholder for latency + nr_latency_tests + 1, + vec![0], // Single "payload size" for latency + )); + for _i in 0..=nr_latency_tests { let latency = test_latency(client); measurements.push(latency); @@ -86,7 +102,6 @@ pub fn run_latency_test_tui( let avg_latency = measurements.iter().sum::() / measurements.len() as f64; (measurements, avg_latency) } - pub fn run_tests_tui( client: &Client, test_fn: fn(&Client, usize, OutputFormat) -> f64, @@ -98,16 +113,22 @@ pub fn run_tests_tui( ) -> Vec { let mut measurements: Vec = Vec::new(); - for payload_size in payload_sizes { - let _ = event_sender.send(TestEvent::TestStarted(test_type, payload_size)); + for (payload_index, payload_size) in payload_sizes.iter().enumerate() { + let _ = event_sender.send(TestEvent::PayloadSizeStarted( + test_type, + *payload_size, + payload_index, + )); let start = Instant::now(); for _i in 0..nr_tests { - let mbit = test_fn(client, payload_size, OutputFormat::None); + let _ = event_sender.send(TestEvent::TestStarted(test_type, *payload_size)); + + let mbit = test_fn(client, *payload_size, OutputFormat::None); let measurement = Measurement { test_type, - payload_size, + payload_size: *payload_size, mbit, }; measurements.push(measurement.clone()); @@ -116,23 +137,36 @@ pub fn run_tests_tui( timestamp: Instant::now(), speed: mbit, test_type, - payload_size, + payload_size: *payload_size, })); - let _ = event_sender.send(TestEvent::TestCompleted(test_type, payload_size)); + let _ = event_sender.send(TestEvent::TestCompleted(test_type, *payload_size)); // Small delay to make the UI updates visible thread::sleep(Duration::from_millis(100)); } + let _ = event_sender.send(TestEvent::PayloadSizeCompleted(test_type, *payload_size)); + let duration = start.elapsed(); // Check time threshold for dynamic payload sizing if !disable_dynamic_max_payload_size && duration > TIME_THRESHOLD { log::info!("Exceeded threshold"); + let _ = event_sender.send(TestEvent::TestsSkipped( + test_type, + "time limit exceeded".to_string(), + )); break; } } + // Calculate average speed for this test type + if !measurements.is_empty() { + let total_speed: f64 = measurements.iter().map(|m| m.mbit).sum(); + let average_speed = total_speed / measurements.len() as f64; + let _ = event_sender.send(TestEvent::TestPhaseCompleted(test_type, average_speed)); + } + measurements } diff --git a/src/tui/app.rs b/src/tui/app.rs index dbc59e6..0f13025 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,13 +1,12 @@ use crate::measurements::Measurement; -use crate::speedtest::{TestType, Metadata}; +use crate::speedtest::{Metadata, TestType}; use crossbeam_channel::Receiver; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Gauge, Paragraph}, + widgets::{Block, Borders, Paragraph}, Frame, Terminal, }; use std::collections::VecDeque; @@ -34,6 +33,11 @@ pub enum TestEvent { LatencyMeasurement(LatencyData), TestStarted(TestType, usize), TestCompleted(TestType, usize), + TestPhaseStarted(TestType, u32, Vec), // test_type, nr_tests, payload_sizes + PayloadSizeStarted(TestType, usize, usize), // test_type, payload_size, payload_index + PayloadSizeCompleted(TestType, usize), + TestPhaseCompleted(TestType, f64), // test_type, average_speed + TestsSkipped(TestType, String), // test_type, reason AllTestsCompleted, MetadataReceived(Metadata), Error(String), @@ -46,6 +50,20 @@ pub struct TestProgress { pub current_iteration: u32, pub total_iterations: u32, pub phase: TestPhase, + pub download_completed_tests: u32, + pub download_total_tests: u32, + pub upload_completed_tests: u32, + pub upload_total_tests: u32, + pub current_payload_index: usize, + pub total_payload_sizes: usize, + pub download_status: String, + pub upload_status: String, + pub download_current_speed: f64, + pub upload_current_speed: f64, + pub download_average_speed: f64, + pub upload_average_speed: f64, + pub download_completed_payload_sizes: usize, + pub upload_completed_payload_sizes: usize, } #[derive(Debug, Clone, PartialEq)] @@ -86,6 +104,20 @@ impl Default for DashboardState { current_iteration: 0, total_iterations: 0, phase: TestPhase::Idle, + download_completed_tests: 0, + download_total_tests: 0, + upload_completed_tests: 0, + upload_total_tests: 0, + current_payload_index: 0, + total_payload_sizes: 0, + download_status: "Waiting...".to_string(), + upload_status: "Waiting...".to_string(), + download_current_speed: 0.0, + upload_current_speed: 0.0, + download_average_speed: 0.0, + upload_average_speed: 0.0, + download_completed_payload_sizes: 0, + upload_completed_payload_sizes: 0, }, current_download_speed: 0.0, current_upload_speed: 0.0, @@ -108,17 +140,43 @@ impl DashboardState { match data.test_type { TestType::Download => { self.current_download_speed = data.speed; + self.progress.download_current_speed = data.speed; self.download_speeds.push_back(data.clone()); if self.download_speeds.len() > self.max_data_points { self.download_speeds.pop_front(); } + + // Update status message + if let Some(payload_size) = self.progress.current_payload_size { + let payload_mb = payload_size / 1_000_000; + self.progress.download_status = format!( + "Testing {}MB [{}/{}] - Current: {:.1} Mbps", + payload_mb, + self.progress.current_iteration + 1, + self.progress.total_iterations, + data.speed + ); + } } TestType::Upload => { self.current_upload_speed = data.speed; + self.progress.upload_current_speed = data.speed; self.upload_speeds.push_back(data.clone()); if self.upload_speeds.len() > self.max_data_points { self.upload_speeds.pop_front(); } + + // Update status message + if let Some(payload_size) = self.progress.current_payload_size { + let payload_mb = payload_size / 1_000_000; + self.progress.upload_status = format!( + "Testing {}MB [{}/{}] - Current: {:.1} Mbps", + payload_mb, + self.progress.current_iteration + 1, + self.progress.total_iterations, + data.speed + ); + } } } @@ -153,9 +211,88 @@ impl DashboardState { TestType::Upload => TestPhase::Upload, }; } - TestEvent::TestCompleted(_, _) => { + TestEvent::TestCompleted(test_type, _) => { self.progress.current_iteration += 1; + match test_type { + TestType::Download => { + self.progress.download_completed_tests += 1; + } + TestType::Upload => { + self.progress.upload_completed_tests += 1; + } + } + } + TestEvent::TestPhaseStarted(test_type, nr_tests, payload_sizes) => { + self.progress.phase = match test_type { + TestType::Download => TestPhase::Download, + TestType::Upload => TestPhase::Upload, + }; + self.progress.total_payload_sizes = payload_sizes.len(); + let total_tests = nr_tests * payload_sizes.len() as u32; + match test_type { + TestType::Download => { + self.progress.download_total_tests = total_tests; + self.progress.download_completed_tests = 0; + self.progress.download_completed_payload_sizes = 0; + self.progress.download_status = "Starting...".to_string(); + } + TestType::Upload => { + self.progress.upload_total_tests = total_tests; + self.progress.upload_completed_tests = 0; + self.progress.upload_completed_payload_sizes = 0; + self.progress.upload_status = "Starting...".to_string(); + } + } } + TestEvent::PayloadSizeStarted(test_type, payload_size, payload_index) => { + self.progress.current_test = Some(test_type); + self.progress.current_payload_size = Some(payload_size); + self.progress.current_payload_index = payload_index; + self.progress.current_iteration = 0; + // Calculate total iterations for this payload size + let total_tests_for_phase = match test_type { + TestType::Download => self.progress.download_total_tests, + TestType::Upload => self.progress.upload_total_tests, + }; + self.progress.total_iterations = + total_tests_for_phase / self.progress.total_payload_sizes as u32; + } + TestEvent::PayloadSizeCompleted(test_type, _) => { + // Payload size completed, reset current iteration + self.progress.current_iteration = 0; + match test_type { + TestType::Download => { + self.progress.download_completed_payload_sizes += 1; + } + TestType::Upload => { + self.progress.upload_completed_payload_sizes += 1; + } + } + } + TestEvent::TestPhaseCompleted(test_type, average_speed) => match test_type { + TestType::Download => { + self.progress.download_average_speed = average_speed; + self.progress.download_status = format!( + "Completed - Average: {:.1} Mbps ({} payload sizes tested)", + average_speed, self.progress.download_completed_payload_sizes + ); + } + TestType::Upload => { + self.progress.upload_average_speed = average_speed; + self.progress.upload_status = format!( + "Completed - Average: {:.1} Mbps ({} payload sizes tested)", + average_speed, self.progress.upload_completed_payload_sizes + ); + } + }, + TestEvent::TestsSkipped(test_type, reason) => match test_type { + TestType::Download => { + self.progress.download_status = format!("Skipped ({})", reason); + } + TestType::Upload => { + self.progress.upload_status = format!("Skipped ({})", reason); + } + }, TestEvent::AllTestsCompleted => { self.progress.phase = TestPhase::Completed; self.progress.current_test = None; @@ -232,14 +369,14 @@ impl App { .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // Title - Constraint::Length(3), // Progress bars + Constraint::Length(4), // Progress bars + Latency stats Constraint::Min(10), // Main content Constraint::Length(3), // Status ]) .split(f.area()); self.draw_title(f, chunks[0]); - self.draw_progress_bars(f, chunks[1]); + self.draw_progress_and_latency(f, chunks[1]); self.draw_main_content(f, chunks[2]); self.draw_status(f, chunks[3]); } @@ -260,47 +397,118 @@ impl App { f.render_widget(title, area); } - fn draw_progress_bars(&self, f: &mut Frame, area: Rect) { + fn draw_progress_and_latency(&self, f: &mut Frame, area: Rect) { let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Download status + Constraint::Length(1), // Upload status + Constraint::Length(1), // Latency stats + ]) .split(area); - let download_progress = if self.state.progress.phase == TestPhase::Download { - (self.state.progress.current_iteration as f64 - / self.state.progress.total_iterations as f64) - .min(1.0) - } else if matches!( - self.state.progress.phase, - TestPhase::Upload | TestPhase::Completed - ) { - 1.0 - } else { - 0.0 - }; + // Calculate adaptive progress for visual bars + let download_progress = self.calculate_adaptive_progress(TestType::Download); + let upload_progress = self.calculate_adaptive_progress(TestType::Upload); - let upload_progress = if self.state.progress.phase == TestPhase::Upload { - (self.state.progress.current_iteration as f64 - / self.state.progress.total_iterations as f64) - .min(1.0) - } else if self.state.progress.phase == TestPhase::Completed { - 1.0 - } else { - 0.0 + // Download status with progress bar and text + let download_color = match self.state.progress.phase { + TestPhase::Download => Color::Green, + TestPhase::Completed if download_progress >= 1.0 => Color::Green, + _ if download_progress >= 1.0 => Color::Green, + _ => Color::Gray, }; - let download_gauge = Gauge::default() - .block(Block::default().title("Download").borders(Borders::ALL)) - .gauge_style(Style::default().fg(Color::Green)) - .ratio(download_progress); + let download_text = format!("Download: {}", self.state.progress.download_status); + let download_paragraph = + Paragraph::new(download_text).style(Style::default().fg(download_color)); + f.render_widget(download_paragraph, chunks[0]); + + // Upload status with progress bar and text + let upload_color = match self.state.progress.phase { + TestPhase::Upload => Color::Blue, + TestPhase::Completed if upload_progress >= 1.0 => Color::Blue, + _ if upload_progress >= 1.0 => Color::Blue, + _ => Color::Gray, + }; - let upload_gauge = Gauge::default() - .block(Block::default().title("Upload").borders(Borders::ALL)) - .gauge_style(Style::default().fg(Color::Blue)) - .ratio(upload_progress); + let upload_text = format!("Upload: {}", self.state.progress.upload_status); + let upload_paragraph = Paragraph::new(upload_text).style(Style::default().fg(upload_color)); + f.render_widget(upload_paragraph, chunks[1]); + + // Latency stats in a compact single line + let latency_text = format!( + "Latency: Current: {:.1}ms | Average: {:.1}ms | Min/Max: {:.1}ms / {:.1}ms", + self.state.current_latency, + self.state.avg_latency, + if self.state.min_latency == f64::MAX { + 0.0 + } else { + self.state.min_latency + }, + self.state.max_latency + ); + let latency_paragraph = + Paragraph::new(latency_text).style(Style::default().fg(Color::Yellow)); + f.render_widget(latency_paragraph, chunks[2]); + } - f.render_widget(download_gauge, chunks[0]); - f.render_widget(upload_gauge, chunks[1]); + fn calculate_adaptive_progress(&self, test_type: TestType) -> f64 { + match test_type { + TestType::Download => { + if self.state.progress.download_total_tests > 0 { + let completed = self.state.progress.download_completed_tests as f64; + let total = self.state.progress.download_total_tests as f64; + + // Add partial progress for current test if in download phase + let current_progress = if self.state.progress.phase == TestPhase::Download { + let current_test_progress = if self.state.progress.total_iterations > 0 { + self.state.progress.current_iteration as f64 + / self.state.progress.total_iterations as f64 + } else { + 0.0 + }; + current_test_progress / total + } else { + 0.0 + }; + + ((completed + current_progress) / total).min(1.0) + } else if matches!( + self.state.progress.phase, + TestPhase::Upload | TestPhase::Completed + ) { + 1.0 + } else { + 0.0 + } + } + TestType::Upload => { + if self.state.progress.upload_total_tests > 0 { + let completed = self.state.progress.upload_completed_tests as f64; + let total = self.state.progress.upload_total_tests as f64; + + // Add partial progress for current test if in upload phase + let current_progress = if self.state.progress.phase == TestPhase::Upload { + let current_test_progress = if self.state.progress.total_iterations > 0 { + self.state.progress.current_iteration as f64 + / self.state.progress.total_iterations as f64 + } else { + 0.0 + }; + current_test_progress / total + } else { + 0.0 + }; + + ((completed + current_progress) / total).min(1.0) + } else if self.state.progress.phase == TestPhase::Completed { + 1.0 + } else { + 0.0 + } + } + } } fn draw_main_content(&self, f: &mut Frame, area: Rect) { @@ -310,7 +518,7 @@ impl App { .split(area); self.draw_speed_graphs(f, chunks[0]); - self.draw_stats_and_boxplots(f, chunks[1]); + self.draw_boxplots(f, chunks[1]); } fn draw_speed_graphs(&self, f: &mut Frame, area: Rect) { @@ -360,70 +568,9 @@ impl App { } } - fn draw_stats_and_boxplots(&self, f: &mut Frame, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(area); - - self.draw_latency_stats(f, chunks[0]); - self.draw_boxplots(f, chunks[1]); - } - - fn draw_latency_stats(&self, f: &mut Frame, area: Rect) { - let stats_text = vec![ - Line::from(vec![ - Span::raw("Current: "), - Span::styled( - format!("{:.1}ms", self.state.current_latency), - Style::default().fg(Color::Yellow), - ), - ]), - Line::from(vec![ - Span::raw("Average: "), - Span::styled( - format!("{:.1}ms", self.state.avg_latency), - Style::default().fg(Color::Green), - ), - ]), - Line::from(vec![ - Span::raw("Min/Max: "), - Span::styled( - format!( - "{:.1}ms / {:.1}ms", - if self.state.min_latency == f64::MAX { - 0.0 - } else { - self.state.min_latency - }, - self.state.max_latency - ), - Style::default().fg(Color::Cyan), - ), - ]), - ]; - - let paragraph = Paragraph::new(stats_text).block( - Block::default() - .title("Latency Stats") - .borders(Borders::ALL), - ); - f.render_widget(paragraph, area); - } - fn draw_boxplots(&self, f: &mut Frame, area: Rect) { - let boxplot_text = vec![ - Line::from("Download: |----[===:===]----|"), - Line::from("Upload: |--[=====:=====]--|"), - Line::from("Latency: |-----[=:=]-------|"), - ]; - - let paragraph = Paragraph::new(boxplot_text).block( - Block::default() - .title("Measurement Boxplots") - .borders(Borders::ALL), - ); - f.render_widget(paragraph, area); + let boxplot_grid = crate::tui::widgets::BoxplotGrid::new(&self.state.measurements); + f.render_widget(boxplot_grid, area); } fn draw_status(&self, f: &mut Frame, area: Rect) { diff --git a/src/tui/dashboard.rs b/src/tui/dashboard.rs index 2841965..df7db25 100644 --- a/src/tui/dashboard.rs +++ b/src/tui/dashboard.rs @@ -2,8 +2,7 @@ use crate::tui::app::{DashboardState, TestPhase}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Gauge, Paragraph}, + widgets::{Block, Borders, Paragraph}, Frame, }; @@ -12,14 +11,14 @@ pub fn render_dashboard(f: &mut Frame, state: &DashboardState) { .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // Title - Constraint::Length(3), // Progress bars + Constraint::Length(4), // Progress bars + Latency stats Constraint::Min(10), // Main content Constraint::Length(3), // Status ]) .split(f.area()); render_title(f, chunks[0], state); - render_progress_bars(f, chunks[1], state); + render_progress_and_latency(f, chunks[1], state); render_main_content(f, chunks[2], state); render_status(f, chunks[3], state); } @@ -40,48 +39,55 @@ fn render_title(f: &mut Frame, area: Rect, state: &DashboardState) { f.render_widget(title, area); } -fn render_progress_bars(f: &mut Frame, area: Rect, state: &DashboardState) { +fn render_progress_and_latency(f: &mut Frame, area: Rect, state: &DashboardState) { let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Download status + Constraint::Length(1), // Upload status + Constraint::Length(1), // Latency stats + ]) .split(area); - let download_progress = match state.progress.phase { - TestPhase::Download => { - if state.progress.total_iterations > 0 { - state.progress.current_iteration as f64 / state.progress.total_iterations as f64 - } else { - 0.0 - } - } - TestPhase::Upload | TestPhase::Completed => 1.0, - _ => 0.0, + // Download status + let download_color = match state.progress.phase { + TestPhase::Download => Color::Green, + TestPhase::Completed => Color::Green, + _ if state.progress.download_status.contains("Completed") => Color::Green, + _ => Color::Gray, }; - let upload_progress = match state.progress.phase { - TestPhase::Upload => { - if state.progress.total_iterations > 0 { - state.progress.current_iteration as f64 / state.progress.total_iterations as f64 - } else { - 0.0 - } - } - TestPhase::Completed => 1.0, - _ => 0.0, + let download_text = format!("Download: {}", state.progress.download_status); + let download_paragraph = + Paragraph::new(download_text).style(Style::default().fg(download_color)); + f.render_widget(download_paragraph, chunks[0]); + + // Upload status + let upload_color = match state.progress.phase { + TestPhase::Upload => Color::Blue, + TestPhase::Completed => Color::Blue, + _ if state.progress.upload_status.contains("Completed") => Color::Blue, + _ => Color::Gray, }; - let download_gauge = Gauge::default() - .block(Block::default().title("Download").borders(Borders::ALL)) - .gauge_style(Style::default().fg(Color::Green)) - .ratio(download_progress.min(1.0)); - - let upload_gauge = Gauge::default() - .block(Block::default().title("Upload").borders(Borders::ALL)) - .gauge_style(Style::default().fg(Color::Blue)) - .ratio(upload_progress.min(1.0)); - - f.render_widget(download_gauge, chunks[0]); - f.render_widget(upload_gauge, chunks[1]); + let upload_text = format!("Upload: {}", state.progress.upload_status); + let upload_paragraph = Paragraph::new(upload_text).style(Style::default().fg(upload_color)); + f.render_widget(upload_paragraph, chunks[1]); + + // Latency stats in a compact single line + let latency_text = format!( + "Latency: Current: {:.1}ms | Average: {:.1}ms | Min/Max: {:.1}ms / {:.1}ms", + state.current_latency, + state.avg_latency, + if state.min_latency == f64::MAX { + 0.0 + } else { + state.min_latency + }, + state.max_latency + ); + let latency_paragraph = Paragraph::new(latency_text).style(Style::default().fg(Color::Yellow)); + f.render_widget(latency_paragraph, chunks[2]); } fn render_main_content(f: &mut Frame, area: Rect, state: &DashboardState) { @@ -91,7 +97,7 @@ fn render_main_content(f: &mut Frame, area: Rect, state: &DashboardState) { .split(area); render_speed_graphs(f, chunks[0], state); - render_stats_and_boxplots(f, chunks[1], state); + render_boxplots(f, chunks[1], state); } fn render_speed_graphs(f: &mut Frame, area: Rect, state: &DashboardState) { @@ -146,70 +152,9 @@ fn render_upload_graph(f: &mut Frame, area: Rect, state: &DashboardState) { } } -fn render_stats_and_boxplots(f: &mut Frame, area: Rect, state: &DashboardState) { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(area); - - render_latency_stats(f, chunks[0], state); - render_boxplots(f, chunks[1], state); -} - -fn render_latency_stats(f: &mut Frame, area: Rect, state: &DashboardState) { - let stats_text = vec![ - Line::from(vec![ - Span::raw("Current: "), - Span::styled( - format!("{:.1}ms", state.current_latency), - Style::default().fg(Color::Yellow), - ), - ]), - Line::from(vec![ - Span::raw("Average: "), - Span::styled( - format!("{:.1}ms", state.avg_latency), - Style::default().fg(Color::Green), - ), - ]), - Line::from(vec![ - Span::raw("Min/Max: "), - Span::styled( - format!( - "{:.1}ms / {:.1}ms", - if state.min_latency == f64::MAX { - 0.0 - } else { - state.min_latency - }, - state.max_latency - ), - Style::default().fg(Color::Cyan), - ), - ]), - ]; - - let paragraph = Paragraph::new(stats_text).block( - Block::default() - .title("Latency Stats") - .borders(Borders::ALL), - ); - f.render_widget(paragraph, area); -} - -fn render_boxplots(f: &mut Frame, area: Rect, _state: &DashboardState) { - let boxplot_text = vec![ - Line::from("Download: |----[===:===]----|"), - Line::from("Upload: |--[=====:=====]--|"), - Line::from("Latency: |-----[=:=]-------|"), - ]; - - let paragraph = Paragraph::new(boxplot_text).block( - Block::default() - .title("Measurement Boxplots") - .borders(Borders::ALL), - ); - f.render_widget(paragraph, area); +fn render_boxplots(f: &mut Frame, area: Rect, state: &DashboardState) { + let boxplot_grid = crate::tui::widgets::BoxplotGrid::new(&state.measurements); + f.render_widget(boxplot_grid, area); } fn render_status(f: &mut Frame, area: Rect, state: &DashboardState) { diff --git a/src/tui/widgets.rs b/src/tui/widgets.rs index f28233e..299a409 100644 --- a/src/tui/widgets.rs +++ b/src/tui/widgets.rs @@ -1,14 +1,17 @@ +use crate::measurements::{format_bytes, Measurement}; +use crate::speedtest::TestType; use crate::tui::app::SpeedData; use ratatui::{ buffer::Buffer, - layout::Rect, - style::Color, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, widgets::{ - canvas::{Canvas, Line, Points}, - Widget, + canvas::{Canvas, Line as CanvasLine, Points}, + Block, Borders, Paragraph, Widget, }, }; -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; pub struct LineGraph<'a> { data: &'a VecDeque, @@ -67,7 +70,7 @@ impl<'a> Widget for LineGraph<'a> { // Draw the line graph for window in points.windows(2) { if let [p1, p2] = window { - ctx.draw(&Line { + ctx.draw(&CanvasLine { x1: p1.0, y1: p1.1, x2: p2.0, @@ -164,3 +167,314 @@ impl<'a> Widget for SimpleLineChart<'a> { } } } + +#[derive(Debug, Clone)] +pub struct BoxplotData { + pub test_type: TestType, + pub payload_size: usize, + pub min: f64, + pub q1: f64, + pub median: f64, + pub q3: f64, + pub max: f64, + pub avg: f64, + pub count: usize, +} + +impl BoxplotData { + pub fn from_measurements( + measurements: &[Measurement], + test_type: TestType, + payload_size: usize, + ) -> Option { + let filtered: Vec = measurements + .iter() + .filter(|m| m.test_type == test_type && m.payload_size == payload_size) + .map(|m| m.mbit) + .collect(); + + if filtered.is_empty() { + return None; + } + + let (min, q1, median, q3, max, avg) = crate::measurements::calc_stats(filtered.clone())?; + + Some(BoxplotData { + test_type, + payload_size, + min, + q1, + median, + q3, + max, + avg, + count: filtered.len(), + }) + } + + pub fn title(&self) -> String { + format!("{:?} {}", self.test_type, format_bytes(self.payload_size)) + } + + pub fn color(&self) -> Color { + match self.test_type { + TestType::Download => Color::Green, + TestType::Upload => Color::Blue, + } + } +} + +pub struct BoxplotWidget<'a> { + data: &'a BoxplotData, + width: u16, +} + +impl<'a> BoxplotWidget<'a> { + pub fn new(data: &'a BoxplotData) -> Self { + Self { + data, + width: 40, // Default width + } + } + + pub fn width(mut self, width: u16) -> Self { + self.width = width; + self + } + + fn render_boxplot_line(&self, area_width: u16) -> String { + let width = (area_width.saturating_sub(2)) as usize; // Account for borders + if width < 10 { + return "Too narrow".to_string(); + } + + let range = self.data.max - self.data.min; + if range == 0.0 { + // All values are the same + let middle = width / 2; + let mut line = vec![' '; width]; + if middle < width { + line[middle] = '│'; + } + return line.into_iter().collect(); + } + + let scale = (width - 1) as f64 / range; + + // Calculate positions + let min_pos = 0; + let q1_pos = ((self.data.q1 - self.data.min) * scale) as usize; + let median_pos = ((self.data.median - self.data.min) * scale) as usize; + let q3_pos = ((self.data.q3 - self.data.min) * scale) as usize; + let max_pos = width - 1; + + let mut line = vec![' '; width]; + + // Draw whiskers + for item in line + .iter_mut() + .take(q1_pos.min(width - 1) + 1) + .skip(min_pos) + { + *item = '─'; + } + for item in line + .iter_mut() + .take(max_pos.min(width - 1) + 1) + .skip(q3_pos) + { + *item = '─'; + } + + // Draw box + for item in line.iter_mut().take(q3_pos.min(width - 1) + 1).skip(q1_pos) { + *item = '█'; + } + + // Draw markers + if min_pos < width { + line[min_pos] = '├'; + } + if q1_pos < width { + line[q1_pos] = '┤'; + } + if median_pos < width { + line[median_pos] = '│'; + } + if q3_pos < width { + line[q3_pos] = '├'; + } + if max_pos < width { + line[max_pos] = '┤'; + } + + line.into_iter().collect() + } +} + +impl<'a> Widget for BoxplotWidget<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let block = Block::default() + .title(self.data.title()) + .borders(Borders::ALL) + .border_style(Style::default().fg(self.data.color())); + + let inner = block.inner(area); + block.render(area, buf); + + if inner.height < 4 { + return; // Not enough space + } + + // Create content lines + let boxplot_line = self.render_boxplot_line(inner.width); + + let content = vec![ + Line::from(vec![ + Span::raw("Count: "), + Span::styled( + format!("{}", self.data.count), + Style::default().fg(Color::Yellow), + ), + ]), + Line::from(boxplot_line), + Line::from(vec![ + Span::raw("Min: "), + Span::styled( + format!("{:.1}", self.data.min), + Style::default().fg(Color::Cyan), + ), + Span::raw(" Max: "), + Span::styled( + format!("{:.1}", self.data.max), + Style::default().fg(Color::Cyan), + ), + ]), + Line::from(vec![ + Span::raw("Avg: "), + Span::styled( + format!("{:.1}", self.data.avg), + Style::default().fg(Color::White), + ), + Span::raw(" Med: "), + Span::styled( + format!("{:.1}", self.data.median), + Style::default().fg(Color::White), + ), + ]), + ]; + + let paragraph = Paragraph::new(content); + paragraph.render(inner, buf); + } +} + +pub struct BoxplotGrid { + boxplots: Vec, +} + +impl BoxplotGrid { + pub fn new(measurements: &[Measurement]) -> Self { + let mut boxplots = Vec::new(); + let mut combinations = HashMap::new(); + + // Find all unique test_type + payload_size combinations + for measurement in measurements { + combinations.insert((measurement.test_type, measurement.payload_size), ()); + } + + // Create boxplot data for each combination + for (test_type, payload_size) in combinations.keys() { + if let Some(boxplot_data) = + BoxplotData::from_measurements(measurements, *test_type, *payload_size) + { + boxplots.push(boxplot_data); + } + } + + // Sort by test type first, then by payload size + boxplots.sort_by(|a, b| match a.test_type.cmp(&b.test_type) { + std::cmp::Ordering::Equal => a.payload_size.cmp(&b.payload_size), + other => other, + }); + + Self { boxplots } + } +} + +impl Widget for BoxplotGrid { + fn render(self, area: Rect, buf: &mut Buffer) { + if self.boxplots.is_empty() { + let placeholder = Paragraph::new("No measurement data available yet...") + .style(Style::default().fg(Color::Gray)) + .block( + Block::default() + .title("Measurement Boxplots") + .borders(Borders::ALL), + ); + placeholder.render(area, buf); + return; + } + + let block = Block::default() + .title("Measurement Boxplots") + .borders(Borders::ALL); + + let inner = block.inner(area); + block.render(area, buf); + + // Calculate layout - try to fit boxplots in a grid + let boxplot_count = self.boxplots.len(); + if boxplot_count == 0 { + return; + } + + // Determine grid dimensions based on available space and number of boxplots + let min_boxplot_height = 6; // Minimum height needed for a boxplot + let min_boxplot_width = 25; // Minimum width needed for a boxplot + + let max_rows = (inner.height / min_boxplot_height as u16).max(1) as usize; + let max_cols = (inner.width / min_boxplot_width as u16).max(1) as usize; + + let cols = (boxplot_count as f64).sqrt().ceil() as usize; + let cols = cols.min(max_cols).max(1); + let rows = boxplot_count.div_ceil(cols).min(max_rows); + + // Create constraints for rows and columns + let row_constraints: Vec = (0..rows) + .map(|_| Constraint::Length(inner.height / rows as u16)) + .collect(); + + let col_constraints: Vec = (0..cols) + .map(|_| Constraint::Percentage(100 / cols as u16)) + .collect(); + + // Create row layout + let row_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(row_constraints) + .split(inner); + + // Render boxplots in grid + for (row_idx, row_area) in row_chunks.iter().enumerate().take(rows) { + if row_idx * cols >= boxplot_count { + break; + } + + let col_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints(col_constraints.clone()) + .split(*row_area); + + for (col_idx, col_area) in col_chunks.iter().enumerate().take(cols) { + let boxplot_idx = row_idx * cols + col_idx; + if boxplot_idx >= boxplot_count { + break; + } + + let boxplot_widget = BoxplotWidget::new(&self.boxplots[boxplot_idx]); + boxplot_widget.render(*col_area, buf); + } + } + } +} From 9323dfca5392a53d81784012ba0531d4d8eb02fe Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 31 Jul 2025 18:32:15 +0200 Subject: [PATCH 3/6] WIP theming --- src/tui/app.rs | 131 ++++++++++++++++---------- src/tui/dashboard.rs | 126 +++++++++++++++---------- src/tui/mod.rs | 1 + src/tui/theme.rs | 212 +++++++++++++++++++++++++++++++++++++++++++ src/tui/widgets.rs | 42 ++++++--- 5 files changed, 406 insertions(+), 106 deletions(-) create mode 100644 src/tui/theme.rs diff --git a/src/tui/app.rs b/src/tui/app.rs index 0f13025..6c9fb3f 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,11 +1,11 @@ use crate::measurements::Measurement; use crate::speedtest::{Metadata, TestType}; +use crate::tui::theme::{ThemedStyles, TokyoNight}; use crossbeam_channel::Receiver; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Style}, widgets::{Block, Borders, Paragraph}, Frame, Terminal, }; @@ -384,16 +384,20 @@ impl App { fn draw_title(&self, f: &mut Frame, area: Rect) { let title_text = if let Some(ref metadata) = self.state.metadata { format!( - "Cloudflare Speed Test - {} {} | IP: {} | Colo: {}", + " Cloudflare Speed Test - {} {} | IP: {} | Colo: {}", metadata.city, metadata.country, metadata.ip, metadata.colo ) } else { - "Cloudflare Speed Test - Loading...".to_string() + " Cloudflare Speed Test - Loading...".to_string() }; let title = Paragraph::new(title_text) - .style(Style::default().fg(Color::Cyan)) - .block(Block::default().borders(Borders::ALL)); + .style(ThemedStyles::title()) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(ThemedStyles::title_border()), + ); f.render_widget(title, area); } @@ -412,33 +416,36 @@ impl App { let upload_progress = self.calculate_adaptive_progress(TestType::Upload); // Download status with progress bar and text - let download_color = match self.state.progress.phase { - TestPhase::Download => Color::Green, - TestPhase::Completed if download_progress >= 1.0 => Color::Green, - _ if download_progress >= 1.0 => Color::Green, - _ => Color::Gray, + let download_style = match self.state.progress.phase { + TestPhase::Download => ThemedStyles::progress_download_active(), + TestPhase::Completed if download_progress >= 1.0 => { + ThemedStyles::progress_download_complete() + } + _ if download_progress >= 1.0 => ThemedStyles::progress_download_complete(), + _ => ThemedStyles::progress_inactive(), }; - let download_text = format!("Download: {}", self.state.progress.download_status); - let download_paragraph = - Paragraph::new(download_text).style(Style::default().fg(download_color)); + let download_text = format!(" Download: {}", self.state.progress.download_status); + let download_paragraph = Paragraph::new(download_text).style(download_style); f.render_widget(download_paragraph, chunks[0]); // Upload status with progress bar and text - let upload_color = match self.state.progress.phase { - TestPhase::Upload => Color::Blue, - TestPhase::Completed if upload_progress >= 1.0 => Color::Blue, - _ if upload_progress >= 1.0 => Color::Blue, - _ => Color::Gray, + let upload_style = match self.state.progress.phase { + TestPhase::Upload => ThemedStyles::progress_upload_active(), + TestPhase::Completed if upload_progress >= 1.0 => { + ThemedStyles::progress_upload_complete() + } + _ if upload_progress >= 1.0 => ThemedStyles::progress_upload_complete(), + _ => ThemedStyles::progress_inactive(), }; - let upload_text = format!("Upload: {}", self.state.progress.upload_status); - let upload_paragraph = Paragraph::new(upload_text).style(Style::default().fg(upload_color)); + let upload_text = format!(" Upload: {}", self.state.progress.upload_status); + let upload_paragraph = Paragraph::new(upload_text).style(upload_style); f.render_widget(upload_paragraph, chunks[1]); // Latency stats in a compact single line let latency_text = format!( - "Latency: Current: {:.1}ms | Average: {:.1}ms | Min/Max: {:.1}ms / {:.1}ms", + " Latency: Current: {:.1}ms | Average: {:.1}ms | Min/Max: {:.1}ms / {:.1}ms", self.state.current_latency, self.state.avg_latency, if self.state.min_latency == f64::MAX { @@ -448,8 +455,7 @@ impl App { }, self.state.max_latency ); - let latency_paragraph = - Paragraph::new(latency_text).style(Style::default().fg(Color::Yellow)); + let latency_paragraph = Paragraph::new(latency_text).style(ThemedStyles::latency_stats()); f.render_widget(latency_paragraph, chunks[2]); } @@ -539,15 +545,19 @@ impl App { let block = Block::default() .title(title) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Green)); + .border_style(ThemedStyles::download_graph_border()); let inner = block.inner(area); f.render_widget(block, area); if !self.state.download_speeds.is_empty() { let graph_widget = crate::tui::widgets::LineGraph::new(&self.state.download_speeds) - .color(Color::Green); + .color(TokyoNight::DOWNLOAD_PRIMARY); f.render_widget(graph_widget, inner); + } else { + let placeholder = + Paragraph::new("Waiting for data...").style(ThemedStyles::graph_placeholder()); + f.render_widget(placeholder, inner); } } @@ -556,15 +566,19 @@ impl App { let block = Block::default() .title(title) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Blue)); + .border_style(ThemedStyles::upload_graph_border()); let inner = block.inner(area); f.render_widget(block, area); if !self.state.upload_speeds.is_empty() { - let graph_widget = - crate::tui::widgets::LineGraph::new(&self.state.upload_speeds).color(Color::Blue); + let graph_widget = crate::tui::widgets::LineGraph::new(&self.state.upload_speeds) + .color(TokyoNight::UPLOAD_PRIMARY); f.render_widget(graph_widget, inner); + } else { + let placeholder = + Paragraph::new("Waiting for data...").style(ThemedStyles::graph_placeholder()); + f.render_widget(placeholder, inner); } } @@ -574,22 +588,34 @@ impl App { } fn draw_status(&self, f: &mut Frame, area: Rect) { - let status_text = match self.state.progress.phase { - TestPhase::Idle => "Ready to start tests. Press 'q' to quit.".to_string(), - TestPhase::Latency => "Running latency tests...".to_string(), + let (status_text, status_style) = match self.state.progress.phase { + TestPhase::Idle => ( + " Ready to start tests. Press 'q' to quit.".to_string(), + ThemedStyles::status_idle(), + ), + TestPhase::Latency => ( + " Running latency tests...".to_string(), + ThemedStyles::status_active(), + ), TestPhase::Download => { if let (Some(payload_size), Some(_)) = ( self.state.progress.current_payload_size, self.state.progress.current_test, ) { - format!( - "Testing Download {}MB [{}/{}]", - payload_size / 1_000_000, - self.state.progress.current_iteration, - self.state.progress.total_iterations + ( + format!( + " Testing Download {}MB [{}/{}]", + payload_size / 1_000_000, + self.state.progress.current_iteration, + self.state.progress.total_iterations + ), + ThemedStyles::status_active(), ) } else { - "Testing Download...".to_string() + ( + " Testing Download...".to_string(), + ThemedStyles::status_active(), + ) } } TestPhase::Upload => { @@ -597,22 +623,33 @@ impl App { self.state.progress.current_payload_size, self.state.progress.current_test, ) { - format!( - "Testing Upload {}MB [{}/{}]", - payload_size / 1_000_000, - self.state.progress.current_iteration, - self.state.progress.total_iterations + ( + format!( + " Testing Upload {}MB [{}/{}]", + payload_size / 1_000_000, + self.state.progress.current_iteration, + self.state.progress.total_iterations + ), + ThemedStyles::status_active(), ) } else { - "Testing Upload...".to_string() + ( + " Testing Upload...".to_string(), + ThemedStyles::status_active(), + ) } } - TestPhase::Completed => "All tests completed! Press 'q' to quit.".to_string(), + TestPhase::Completed => ( + " All tests completed! Press 'q' to quit.".to_string(), + ThemedStyles::status_complete(), + ), }; - let paragraph = Paragraph::new(status_text) - .style(Style::default().fg(Color::White)) - .block(Block::default().borders(Borders::ALL)); + let paragraph = Paragraph::new(status_text).style(status_style).block( + Block::default() + .borders(Borders::ALL) + .border_style(ThemedStyles::status_border()), + ); f.render_widget(paragraph, area); } } diff --git a/src/tui/dashboard.rs b/src/tui/dashboard.rs index df7db25..8522159 100644 --- a/src/tui/dashboard.rs +++ b/src/tui/dashboard.rs @@ -1,7 +1,7 @@ use crate::tui::app::{DashboardState, TestPhase}; +use crate::tui::theme::{ThemedStyles, TokyoNight}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Style}, widgets::{Block, Borders, Paragraph}, Frame, }; @@ -26,16 +26,20 @@ pub fn render_dashboard(f: &mut Frame, state: &DashboardState) { fn render_title(f: &mut Frame, area: Rect, state: &DashboardState) { let title_text = if let Some(ref metadata) = state.metadata { format!( - "Cloudflare Speed Test - {} {} | IP: {} | Colo: {}", + " Cloudflare Speed Test - {} {} | IP: {} | Colo: {}", metadata.city, metadata.country, metadata.ip, metadata.colo ) } else { - "Cloudflare Speed Test - Loading...".to_string() + " Cloudflare Speed Test - Loading...".to_string() }; let title = Paragraph::new(title_text) - .style(Style::default().fg(Color::Cyan)) - .block(Block::default().borders(Borders::ALL)); + .style(ThemedStyles::title()) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(ThemedStyles::title_border()), + ); f.render_widget(title, area); } @@ -50,33 +54,36 @@ fn render_progress_and_latency(f: &mut Frame, area: Rect, state: &DashboardState .split(area); // Download status - let download_color = match state.progress.phase { - TestPhase::Download => Color::Green, - TestPhase::Completed => Color::Green, - _ if state.progress.download_status.contains("Completed") => Color::Green, - _ => Color::Gray, + let download_style = match state.progress.phase { + TestPhase::Download => ThemedStyles::progress_download_active(), + TestPhase::Completed => ThemedStyles::progress_download_complete(), + _ if state.progress.download_status.contains("Completed") => { + ThemedStyles::progress_download_complete() + } + _ => ThemedStyles::progress_inactive(), }; - let download_text = format!("Download: {}", state.progress.download_status); - let download_paragraph = - Paragraph::new(download_text).style(Style::default().fg(download_color)); + let download_text = format!(" Download: {}", state.progress.download_status); + let download_paragraph = Paragraph::new(download_text).style(download_style); f.render_widget(download_paragraph, chunks[0]); // Upload status - let upload_color = match state.progress.phase { - TestPhase::Upload => Color::Blue, - TestPhase::Completed => Color::Blue, - _ if state.progress.upload_status.contains("Completed") => Color::Blue, - _ => Color::Gray, + let upload_style = match state.progress.phase { + TestPhase::Upload => ThemedStyles::progress_upload_active(), + TestPhase::Completed => ThemedStyles::progress_upload_complete(), + _ if state.progress.upload_status.contains("Completed") => { + ThemedStyles::progress_upload_complete() + } + _ => ThemedStyles::progress_inactive(), }; - let upload_text = format!("Upload: {}", state.progress.upload_status); - let upload_paragraph = Paragraph::new(upload_text).style(Style::default().fg(upload_color)); + let upload_text = format!(" Upload: {}", state.progress.upload_status); + let upload_paragraph = Paragraph::new(upload_text).style(upload_style); f.render_widget(upload_paragraph, chunks[1]); // Latency stats in a compact single line let latency_text = format!( - "Latency: Current: {:.1}ms | Average: {:.1}ms | Min/Max: {:.1}ms / {:.1}ms", + " Latency: Current: {:.1}ms | Average: {:.1}ms | Min/Max: {:.1}ms / {:.1}ms", state.current_latency, state.avg_latency, if state.min_latency == f64::MAX { @@ -86,7 +93,7 @@ fn render_progress_and_latency(f: &mut Frame, area: Rect, state: &DashboardState }, state.max_latency ); - let latency_paragraph = Paragraph::new(latency_text).style(Style::default().fg(Color::Yellow)); + let latency_paragraph = Paragraph::new(latency_text).style(ThemedStyles::latency_stats()); f.render_widget(latency_paragraph, chunks[2]); } @@ -115,18 +122,19 @@ fn render_download_graph(f: &mut Frame, area: Rect, state: &DashboardState) { let block = Block::default() .title(title) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Green)); + .border_style(ThemedStyles::download_graph_border()); let inner = block.inner(area); f.render_widget(block, area); if !state.download_speeds.is_empty() { let speeds: Vec = state.download_speeds.iter().map(|d| d.speed).collect(); - let graph_widget = crate::tui::widgets::SimpleLineChart::new(&speeds).color(Color::Green); + let graph_widget = + crate::tui::widgets::SimpleLineChart::new(&speeds).color(TokyoNight::DOWNLOAD_PRIMARY); f.render_widget(graph_widget, inner); } else { let placeholder = - Paragraph::new("Waiting for data...").style(Style::default().fg(Color::Gray)); + Paragraph::new("Waiting for data...").style(ThemedStyles::graph_placeholder()); f.render_widget(placeholder, inner); } } @@ -136,18 +144,19 @@ fn render_upload_graph(f: &mut Frame, area: Rect, state: &DashboardState) { let block = Block::default() .title(title) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Blue)); + .border_style(ThemedStyles::upload_graph_border()); let inner = block.inner(area); f.render_widget(block, area); if !state.upload_speeds.is_empty() { let speeds: Vec = state.upload_speeds.iter().map(|d| d.speed).collect(); - let graph_widget = crate::tui::widgets::SimpleLineChart::new(&speeds).color(Color::Blue); + let graph_widget = + crate::tui::widgets::SimpleLineChart::new(&speeds).color(TokyoNight::UPLOAD_PRIMARY); f.render_widget(graph_widget, inner); } else { let placeholder = - Paragraph::new("Waiting for data...").style(Style::default().fg(Color::Gray)); + Paragraph::new("Waiting for data...").style(ThemedStyles::graph_placeholder()); f.render_widget(placeholder, inner); } } @@ -158,38 +167,61 @@ fn render_boxplots(f: &mut Frame, area: Rect, state: &DashboardState) { } fn render_status(f: &mut Frame, area: Rect, state: &DashboardState) { - let status_text = match state.progress.phase { - TestPhase::Idle => "Ready to start tests. Press 'q' to quit.".to_string(), - TestPhase::Latency => "Running latency tests...".to_string(), + let (status_text, status_style) = match state.progress.phase { + TestPhase::Idle => ( + " Ready to start tests. Press 'q' to quit.".to_string(), + ThemedStyles::status_idle(), + ), + TestPhase::Latency => ( + " Running latency tests...".to_string(), + ThemedStyles::status_active(), + ), TestPhase::Download => { if let Some(payload_size) = state.progress.current_payload_size { - format!( - "Testing Download {}MB [{}/{}]", - payload_size / 1_000_000, - state.progress.current_iteration, - state.progress.total_iterations + ( + format!( + " Testing Download {}MB [{}/{}]", + payload_size / 1_000_000, + state.progress.current_iteration, + state.progress.total_iterations + ), + ThemedStyles::status_active(), ) } else { - "Testing Download...".to_string() + ( + " Testing Download...".to_string(), + ThemedStyles::status_active(), + ) } } TestPhase::Upload => { if let Some(payload_size) = state.progress.current_payload_size { - format!( - "Testing Upload {}MB [{}/{}]", - payload_size / 1_000_000, - state.progress.current_iteration, - state.progress.total_iterations + ( + format!( + " Testing Upload {}MB [{}/{}]", + payload_size / 1_000_000, + state.progress.current_iteration, + state.progress.total_iterations + ), + ThemedStyles::status_active(), ) } else { - "Testing Upload...".to_string() + ( + " Testing Upload...".to_string(), + ThemedStyles::status_active(), + ) } } - TestPhase::Completed => "All tests completed! Press 'q' to quit.".to_string(), + TestPhase::Completed => ( + " All tests completed! Press 'q' to quit.".to_string(), + ThemedStyles::status_complete(), + ), }; - let paragraph = Paragraph::new(status_text) - .style(Style::default().fg(Color::White)) - .block(Block::default().borders(Borders::ALL)); + let paragraph = Paragraph::new(status_text).style(status_style).block( + Block::default() + .borders(Borders::ALL) + .border_style(ThemedStyles::status_border()), + ); f.render_widget(paragraph, area); } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 1b65680..f113226 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,6 +1,7 @@ pub mod app; pub mod dashboard; pub mod events; +pub mod theme; pub mod widgets; pub use app::App; diff --git a/src/tui/theme.rs b/src/tui/theme.rs new file mode 100644 index 0000000..0171b1d --- /dev/null +++ b/src/tui/theme.rs @@ -0,0 +1,212 @@ +use ratatui::style::{Color, Style}; + +pub struct TokyoNight; + +impl TokyoNight { + // Core Tokyo Night colors + pub const BACKGROUND: Color = Color::Rgb(26, 27, 38); // #1a1b26 + pub const FOREGROUND: Color = Color::Rgb(192, 202, 245); // #c0caf5 + pub const COMMENT: Color = Color::Rgb(86, 95, 137); // #565f89 + + // Accent colors + pub const PURPLE: Color = Color::Rgb(187, 154, 247); // #bb9af7 + pub const BLUE: Color = Color::Rgb(122, 162, 247); // #7aa2f7 + pub const CYAN: Color = Color::Rgb(125, 207, 255); // #7dcfff + pub const GREEN: Color = Color::Rgb(158, 206, 106); // #9ece6a + pub const YELLOW: Color = Color::Rgb(224, 175, 104); // #e0af68 + pub const ORANGE: Color = Color::Rgb(255, 158, 100); // #ff9e64 + pub const RED: Color = Color::Rgb(247, 118, 142); // #f7768e + pub const MAGENTA: Color = Color::Rgb(187, 154, 247); // #bb9af7 + + // UI specific colors + pub const BORDER: Color = Color::Rgb(86, 95, 137); // #565f89 + pub const BORDER_HIGHLIGHT: Color = Color::Rgb(125, 207, 255); // #7dcfff + pub const SELECTION: Color = Color::Rgb(41, 46, 66); // #292e42 + pub const VISUAL: Color = Color::Rgb(51, 65, 85); // #334155 + + // Status colors + pub const SUCCESS: Color = Self::GREEN; + pub const WARNING: Color = Self::YELLOW; + pub const ERROR: Color = Self::RED; + pub const INFO: Color = Self::BLUE; + + // Graph colors + pub const DOWNLOAD_PRIMARY: Color = Self::GREEN; + pub const DOWNLOAD_SECONDARY: Color = Color::Rgb(134, 180, 92); // Lighter green + pub const UPLOAD_PRIMARY: Color = Self::BLUE; + pub const UPLOAD_SECONDARY: Color = Color::Rgb(100, 140, 220); // Lighter blue + pub const LATENCY_PRIMARY: Color = Self::YELLOW; + pub const LATENCY_SECONDARY: Color = Color::Rgb(200, 160, 90); // Darker yellow + + // Progress colors + pub const PROGRESS_COMPLETE: Color = Self::GREEN; + pub const PROGRESS_ACTIVE: Color = Self::CYAN; + pub const PROGRESS_PENDING: Color = Self::COMMENT; + pub const PROGRESS_BACKGROUND: Color = Color::Rgb(41, 46, 66); // #292e42 +} + +pub struct ThemedStyles; + +impl ThemedStyles { + // Title styles + pub fn title() -> Style { + Style::default() + .fg(TokyoNight::CYAN) + .bg(TokyoNight::BACKGROUND) + } + + pub fn title_border() -> Style { + Style::default().fg(TokyoNight::BORDER_HIGHLIGHT) + } + + // Progress styles + pub fn progress_download_active() -> Style { + Style::default().fg(TokyoNight::DOWNLOAD_PRIMARY) + } + + pub fn progress_download_complete() -> Style { + Style::default().fg(TokyoNight::SUCCESS) + } + + pub fn progress_upload_active() -> Style { + Style::default().fg(TokyoNight::UPLOAD_PRIMARY) + } + + pub fn progress_upload_complete() -> Style { + Style::default().fg(TokyoNight::SUCCESS) + } + + pub fn progress_inactive() -> Style { + Style::default().fg(TokyoNight::COMMENT) + } + + pub fn latency_stats() -> Style { + Style::default().fg(TokyoNight::LATENCY_PRIMARY) + } + + // Graph styles + pub fn download_graph_border() -> Style { + Style::default().fg(TokyoNight::DOWNLOAD_PRIMARY) + } + + pub fn upload_graph_border() -> Style { + Style::default().fg(TokyoNight::UPLOAD_PRIMARY) + } + + pub fn graph_placeholder() -> Style { + Style::default().fg(TokyoNight::COMMENT) + } + + // Boxplot styles + pub fn boxplot_border() -> Style { + Style::default().fg(TokyoNight::BORDER) + } + + pub fn boxplot_download_accent() -> Style { + Style::default().fg(TokyoNight::DOWNLOAD_PRIMARY) + } + + pub fn boxplot_upload_accent() -> Style { + Style::default().fg(TokyoNight::UPLOAD_PRIMARY) + } + + pub fn boxplot_stats() -> Style { + Style::default().fg(TokyoNight::FOREGROUND) + } + + pub fn boxplot_highlight() -> Style { + Style::default().fg(TokyoNight::CYAN) + } + + pub fn boxplot_count() -> Style { + Style::default().fg(TokyoNight::YELLOW) + } + + // Status styles + pub fn status_idle() -> Style { + Style::default().fg(TokyoNight::FOREGROUND) + } + + pub fn status_active() -> Style { + Style::default().fg(TokyoNight::CYAN) + } + + pub fn status_complete() -> Style { + Style::default().fg(TokyoNight::SUCCESS) + } + + pub fn status_border() -> Style { + Style::default().fg(TokyoNight::BORDER) + } + + // General UI styles + pub fn default_border() -> Style { + Style::default().fg(TokyoNight::BORDER) + } + + pub fn highlight_border() -> Style { + Style::default().fg(TokyoNight::BORDER_HIGHLIGHT) + } + + pub fn text_primary() -> Style { + Style::default().fg(TokyoNight::FOREGROUND) + } + + pub fn text_secondary() -> Style { + Style::default().fg(TokyoNight::COMMENT) + } + + pub fn text_accent() -> Style { + Style::default().fg(TokyoNight::PURPLE) + } + + pub fn background() -> Style { + Style::default().bg(TokyoNight::BACKGROUND) + } +} + +// Progress bar rendering utilities +pub struct ProgressBar; + +impl ProgressBar { + pub fn render_bar(progress: f64, width: usize, active: bool) -> String { + let filled_width = (progress * width as f64) as usize; + let empty_width = width.saturating_sub(filled_width); + + let fill_char = if active { '█' } else { '▓' }; + let empty_char = '░'; + + format!( + "{}{}", + fill_char.to_string().repeat(filled_width), + empty_char.to_string().repeat(empty_width) + ) + } + + pub fn render_gradient_bar(progress: f64, width: usize) -> String { + let filled_width = (progress * width as f64) as usize; + let mut bar = String::new(); + + for i in 0..width { + if i < filled_width { + // Use different characters for gradient effect + let intensity = (i as f64 / width as f64 * 4.0) as usize; + let char = match intensity { + 0 => '▏', + 1 => '▎', + 2 => '▍', + 3 => '▌', + 4 => '▋', + 5 => '▊', + 6 => '▉', + _ => '█', + }; + bar.push(char); + } else { + bar.push('░'); + } + } + + bar + } +} diff --git a/src/tui/widgets.rs b/src/tui/widgets.rs index 299a409..3001cb4 100644 --- a/src/tui/widgets.rs +++ b/src/tui/widgets.rs @@ -1,6 +1,7 @@ use crate::measurements::{format_bytes, Measurement}; use crate::speedtest::TestType; use crate::tui::app::SpeedData; +use crate::tui::theme::{ThemedStyles, TokyoNight}; use ratatui::{ buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, @@ -160,7 +161,19 @@ impl<'a> Widget for SimpleLineChart<'a> { if y <= bar_height { if let Some(cell) = buf.cell_mut((screen_x, screen_y)) { - cell.set_char('█').set_fg(self.color); + // Use different characters for gradient effect + let intensity = (y as f64 / height as f64 * 8.0) as usize; + let char = match intensity { + 0..=1 => '▁', + 2 => '▂', + 3 => '▃', + 4 => '▄', + 5 => '▅', + 6 => '▆', + 7 => '▇', + _ => '█', + }; + cell.set_char(char).set_fg(self.color); } } } @@ -218,8 +231,8 @@ impl BoxplotData { pub fn color(&self) -> Color { match self.test_type { - TestType::Download => Color::Green, - TestType::Upload => Color::Blue, + TestType::Download => TokyoNight::DOWNLOAD_PRIMARY, + TestType::Upload => TokyoNight::UPLOAD_PRIMARY, } } } @@ -334,32 +347,35 @@ impl<'a> Widget for BoxplotWidget<'a> { Span::raw("Count: "), Span::styled( format!("{}", self.data.count), - Style::default().fg(Color::Yellow), + ThemedStyles::boxplot_count(), ), ]), - Line::from(boxplot_line), + Line::from(Span::styled( + boxplot_line, + Style::default().fg(self.data.color()), + )), Line::from(vec![ Span::raw("Min: "), Span::styled( format!("{:.1}", self.data.min), - Style::default().fg(Color::Cyan), + ThemedStyles::boxplot_highlight(), ), Span::raw(" Max: "), Span::styled( format!("{:.1}", self.data.max), - Style::default().fg(Color::Cyan), + ThemedStyles::boxplot_highlight(), ), ]), Line::from(vec![ Span::raw("Avg: "), Span::styled( format!("{:.1}", self.data.avg), - Style::default().fg(Color::White), + ThemedStyles::boxplot_stats(), ), Span::raw(" Med: "), Span::styled( format!("{:.1}", self.data.median), - Style::default().fg(Color::White), + ThemedStyles::boxplot_stats(), ), ]), ]; @@ -406,11 +422,12 @@ impl Widget for BoxplotGrid { fn render(self, area: Rect, buf: &mut Buffer) { if self.boxplots.is_empty() { let placeholder = Paragraph::new("No measurement data available yet...") - .style(Style::default().fg(Color::Gray)) + .style(ThemedStyles::graph_placeholder()) .block( Block::default() .title("Measurement Boxplots") - .borders(Borders::ALL), + .borders(Borders::ALL) + .border_style(ThemedStyles::boxplot_border()), ); placeholder.render(area, buf); return; @@ -418,7 +435,8 @@ impl Widget for BoxplotGrid { let block = Block::default() .title("Measurement Boxplots") - .borders(Borders::ALL); + .borders(Borders::ALL) + .border_style(ThemedStyles::highlight_border()); let inner = block.inner(area); block.render(area, buf); From bbaf2536a14805e6db645e1514542498bcce06c1 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 31 Jul 2025 18:39:39 +0200 Subject: [PATCH 4/6] Add AGENTS.md to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e06aed5..b90638b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .aider* CLAUDE.md GEMINI.md +AGENTS.md From b177b89fb8fa123e1e3126f4c525a56559b65fea Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 31 Jul 2025 18:40:27 +0200 Subject: [PATCH 5/6] Add AGENTS.md to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b90638b..024f66e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ CLAUDE.md GEMINI.md AGENTS.md +AGENTS.md From 9865c8407480d3db562ddebcdaa57dec5b3496c3 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 31 Jul 2025 18:45:13 +0200 Subject: [PATCH 6/6] delete agents --- AGENTS.md | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index fe9e8c0..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,25 +0,0 @@ -# AGENTS.md - Development Guide for cfspeedtest - -## Build/Test Commands -- `cargo build` - Build the project -- `cargo test` - Run all tests -- `cargo test test_name` - Run a specific test -- `cargo fmt` - Format code (required before commits) -- `cargo clippy` - Run linter -- `cargo run` - Run the CLI tool -- `cargo run --example simple_speedtest` - Run example - -## Code Style Guidelines -- Use `cargo fmt` for consistent formatting -- Follow Rust 2021 edition conventions -- Use snake_case for functions/variables, PascalCase for types/enums -- Prefer explicit types in public APIs -- Use `Result` for error handling with descriptive messages -- Import std modules first, then external crates, then local modules -- Use `log` crate for logging, `env_logger` for initialization -- Prefer `reqwest::blocking::Client` for HTTP requests -- Use `clap` derive macros for CLI argument parsing -- Write comprehensive unit tests in `#[cfg(test)]` modules -- Use `serde` for serialization with `#[derive(Serialize)]` -- Constants should be SCREAMING_SNAKE_CASE -- Prefer `Duration` and `Instant` for time measurements \ No newline at end of file