diff --git a/.gitignore b/.gitignore index e06aed5..024f66e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .aider* CLAUDE.md GEMINI.md +AGENTS.md +AGENTS.md 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..28ef707 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, @@ -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 a267f2e..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, @@ -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..e749ce3 --- /dev/null +++ b/src/speedtest_tui.rs @@ -0,0 +1,172 @@ +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() { + let _ = event_sender.send(TestEvent::TestPhaseStarted( + TestType::Download, + options.nr_tests, + payload_sizes.clone(), + )); + 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() { + let _ = event_sender.send(TestEvent::TestPhaseStarted( + TestType::Upload, + options.nr_tests, + payload_sizes.clone(), + )); + 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(); + + // 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); + + 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_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 _ = 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, + mbit, + }; + measurements.push(measurement.clone()); + + let _ = event_sender.send(TestEvent::SpeedMeasurement(SpeedData { + timestamp: Instant::now(), + speed: mbit, + test_type, + payload_size: *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 new file mode 100644 index 0000000..6c9fb3f --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,655 @@ +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}, + widgets::{Block, Borders, 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), + 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), +} + +#[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, + 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)] +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, + 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, + 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.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 + ); + } + } + } + + 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(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; + 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(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_and_latency(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(ThemedStyles::title()) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(ThemedStyles::title_border()), + ); + f.render_widget(title, area); + } + + fn draw_progress_and_latency(&self, f: &mut Frame, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Download status + Constraint::Length(1), // Upload status + Constraint::Length(1), // Latency stats + ]) + .split(area); + + // Calculate adaptive progress for visual bars + let download_progress = self.calculate_adaptive_progress(TestType::Download); + let upload_progress = self.calculate_adaptive_progress(TestType::Upload); + + // Download status with progress bar and text + 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(download_style); + f.render_widget(download_paragraph, chunks[0]); + + // Upload status with progress bar and text + 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(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", + 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(ThemedStyles::latency_stats()); + f.render_widget(latency_paragraph, chunks[2]); + } + + 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) { + 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_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(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(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); + } + } + + 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(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(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); + } + } + + fn draw_boxplots(&self, f: &mut Frame, area: Rect) { + 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) { + 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 + ), + ThemedStyles::status_active(), + ) + } else { + ( + " Testing Download...".to_string(), + ThemedStyles::status_active(), + ) + } + } + 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 + ), + ThemedStyles::status_active(), + ) + } else { + ( + " Testing Upload...".to_string(), + ThemedStyles::status_active(), + ) + } + } + TestPhase::Completed => ( + " All tests completed! Press 'q' to quit.".to_string(), + ThemedStyles::status_complete(), + ), + }; + + 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 new file mode 100644 index 0000000..8522159 --- /dev/null +++ b/src/tui/dashboard.rs @@ -0,0 +1,227 @@ +use crate::tui::app::{DashboardState, TestPhase}; +use crate::tui::theme::{ThemedStyles, TokyoNight}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + widgets::{Block, Borders, 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(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_and_latency(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(ThemedStyles::title()) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(ThemedStyles::title_border()), + ); + f.render_widget(title, area); +} + +fn render_progress_and_latency(f: &mut Frame, area: Rect, state: &DashboardState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Download status + Constraint::Length(1), // Upload status + Constraint::Length(1), // Latency stats + ]) + .split(area); + + // Download status + 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(download_style); + f.render_widget(download_paragraph, chunks[0]); + + // Upload status + 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(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", + 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(ThemedStyles::latency_stats()); + f.render_widget(latency_paragraph, chunks[2]); +} + +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_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(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(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); + } +} + +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(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(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); + } +} + +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) { + 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 + ), + ThemedStyles::status_active(), + ) + } else { + ( + " 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 + ), + ThemedStyles::status_active(), + ) + } else { + ( + " Testing Upload...".to_string(), + ThemedStyles::status_active(), + ) + } + } + TestPhase::Completed => ( + " All tests completed! Press 'q' to quit.".to_string(), + ThemedStyles::status_complete(), + ), + }; + + 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/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..f113226 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +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 new file mode 100644 index 0000000..3001cb4 --- /dev/null +++ b/src/tui/widgets.rs @@ -0,0 +1,498 @@ +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}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{ + canvas::{Canvas, Line as CanvasLine, Points}, + Block, Borders, Paragraph, Widget, + }, +}; +use std::collections::{HashMap, 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(&CanvasLine { + 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)) { + // 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); + } + } + } + } + } +} + +#[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 => TokyoNight::DOWNLOAD_PRIMARY, + TestType::Upload => TokyoNight::UPLOAD_PRIMARY, + } + } +} + +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), + ThemedStyles::boxplot_count(), + ), + ]), + 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), + ThemedStyles::boxplot_highlight(), + ), + Span::raw(" Max: "), + Span::styled( + format!("{:.1}", self.data.max), + ThemedStyles::boxplot_highlight(), + ), + ]), + Line::from(vec![ + Span::raw("Avg: "), + Span::styled( + format!("{:.1}", self.data.avg), + ThemedStyles::boxplot_stats(), + ), + Span::raw(" Med: "), + Span::styled( + format!("{:.1}", self.data.median), + ThemedStyles::boxplot_stats(), + ), + ]), + ]; + + 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(ThemedStyles::graph_placeholder()) + .block( + Block::default() + .title("Measurement Boxplots") + .borders(Borders::ALL) + .border_style(ThemedStyles::boxplot_border()), + ); + placeholder.render(area, buf); + return; + } + + let block = Block::default() + .title("Measurement Boxplots") + .borders(Borders::ALL) + .border_style(ThemedStyles::highlight_border()); + + 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); + } + } + } +}